Every time I asked an AI code generator to "make it look like that site", I got the same thing back: a generic card grid with rounded-lg and bg-blue-500. The model had no idea what "that site" looked like. My options were to describe it in a wall of prose, or paste the page's HTML, which is mostly tracking scripts, SVG paths and utility-class soup that burns the context window and still says nothing about what the browser actually rendered.
UIKey is my attempt at a third option. A browser extension walks the live DOM, reads computed styles and bounding boxes, and hands the result to a small compiler that pulls out design tokens and rewrites the tree as compact pseudo-JSX. The output is a short text manifest, a "UI Key", that you can paste into any LLM or drop into your repo as context for an agentic editor.
I built it in a two-day sprint (9 to 10 October 2026, 46 commits) as a monorepo. This post walks through the extraction core, which is the part I'm proudest of, and is upfront about which of the many surfaces around it are real and which are still scaffolds.
The idea: rendered truth, not source
The key decision was to extract from the rendered page, not from the source. Source HTML lies in all the interesting ways: a Tailwind class like p-4 means nothing without the config, CSS variables resolve at runtime, and half the layout comes from a stylesheet three imports away. window.getComputedStyle() and getBoundingClientRect() don't care how the CSS was authored. They give you the final colour as rgb(...), the final padding in pixels and the final box on screen.
That gives a pipeline with clean seams:
- Content scriptPlasmo, all URLsWalks the DOM, reads computed styles and geometry.
- Ingest APINext.js + PrismaStores the tree, mints a 6-char key.
- Token compilerpackages/extractorDedupes colours and font sizes into named tokens.
- Prompt formatterpseudo-JSXRewrites the tree with token references.
- CLI / VS CodeconsumersWrites .uikey/context.md and a .cursorrules hint.
Extraction: one recursive walk
The heart of the system is extractDOM in packages/extractor. It is a single recursive function: for each element it captures tag, id, classes, all attributes, the full bounding rect, a handful of computed style properties, and its direct text, then recurses into element children.
export function extractDOM(rootNode: HTMLElement): ExtractedNode { const rect = rootNode.getBoundingClientRect(); const computedStyle = window.getComputedStyle(rootNode); // ...attributes copied verbatim... const nodeData: ExtractedNode = { tagName: rootNode.tagName.toLowerCase(), id: rootNode.id, className: rootNode.className, attributes, geometry: { x: rect.x, y: rect.y, width: rect.width, height: rect.height, top: rect.top, right: rect.right, bottom: rect.bottom, left: rect.left }, styles: { backgroundColor: computedStyle.backgroundColor, color: computedStyle.color, padding: computedStyle.padding, fontSize: computedStyle.fontSize, }, children: [], }; // ...accessibility checks, then recurse over childNodes... }
Text handling is deliberately simple: only TEXT_NODE children directly under an element are concatenated into that element's text, so a button's label lands on the button and not on its wrapper div.
The browser side is tiny. The Plasmo content script matches <all_urls>, listens for one message, and runs the walk on document.body:
export const config: PlasmoCSConfig = { matches: ["<all_urls>"] } chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (message.action === "EXTRACT_UI") { try { const domData = extractDOM(document.body) sendResponse({ success: true, uiKey: JSON.stringify(domData, null, 2) }) } catch (e) { sendResponse({ success: false, uiKey: String(e) }) } } })
The popup is a React component with a single Extract Current Site button. It messages the active tab, POSTs the JSON to the ingest route, and shows the returned uikey.dev/k/... link with a copy button. Plasmo earned its place here: Manifest V3 boilerplate, hot reload and a React popup for free, which matters a lot when you only have two days.
Accessibility, for free
Since the walk already has every element's attributes and colours in hand, I bolted on a cheap audit. Each node can carry an accessibility block that flags:
imgelements with noaltattribute,input/textarea/selectwith noaria-label,aria-labelledbyortitle, and buttons or links with no label and no text,- foreground/background pairs below a 4.5:1 WCAG contrast ratio, computed with the standard relative-luminance formula.
The reasoning: if the manifest is going to teach an LLM how to rebuild a UI, it should not teach it to rebuild the accessibility bugs too.
Compiling tokens
Raw computed styles are repetitive. A page might have 400 nodes and six actual colours. generateDesignTokens walks the tree once, collects every distinct color, backgroundColor and fontSize into sets, and numbers them:
let colorIndex = 1; for (const color of colorSet) { designSystem.colors[`color-${colorIndex++}`] = color; } let fontIndex = 1; for (const font of fontSet) { designSystem.fonts[`size-${fontIndex++}`] = font; }
Before formatting, sanitizeDOMTree drops script, style and svg subtrees entirely. SVG was the hardest call. Icons matter visually, but path data is enormous and an LLM can't do anything useful with a 2 KB d attribute. Dropping it was the single biggest size win.
Formatting for a model, not a browser
The last stage, generateAIPrompt, is where the size actually collapses. It emits a markdown header with the token table, then rewrites the tree as indented pseudo-JSX where every style value is replaced by a reference to its token through a reverse lookup map. Transparent backgrounds and zero padding are omitted.
const bg = node.styles.backgroundColor; if (bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent') { attrs += ` bg="${reverseColorMap[bg] || bg}"`; } // ...same for color, padding (p=), fontSize (fs=)
So a red button with white text comes out as something like this, which is exactly what the Jest pipeline test in packages/extractor/tests/pipeline.test.ts asserts against:
# UI DESIGN SYSTEM INSTRUCTIONS Adopt the following design tokens when generating UI components. # Design Tokens ## Colors - color-1: rgb(255, 0, 0) - color-2: rgb(255, 255, 255) ## Fonts - size-1: 16px # UI Layout <div id="root" ...> <button bg="color-1" color="color-2" p="10px 20px" fs="size-1">Click Me</button> </div>
Why pseudo-JSX and not JSON? Two reasons. Models have seen far more JSX than any bespoke schema, so they read nesting and attributes natively. And JSON repeats every key on every node ("backgroundColor":, "children":), while short attribute names like bg, p and fs cost a token or two each.
| Representation | What the model sees per node |
|---|---|
| Raw HTML | class soup, inline scripts, SVG paths, no rendered values |
| Extracted JSON | every key repeated, full geometry, all attributes |
| UI Key pseudo-JSX | tag, token refs, padding, text |
Storing and sharing keys
apps/web is a Next.js app with a Prisma model that's about as small as it can be:
model UIComponent { id String @id @default(cuid()) shortId String @unique payload String userId String? isPublic Boolean @default(false) createdAt DateTime @default(now()) }
POST /api/ingest generates a 6-character hex shortId from crypto.randomBytes, stores the payload, attaches the NextAuth user's email if signed in, and returns uikey.dev/k/<id>. GET /api/k/[id] serves it back as plain text, and PATCH/PUT on /api/keys/[id] let the owner flip isPublic (which feeds an explore page) or edit the payload. The PATCH and PUT handlers check the session email against userId and return 403 otherwise.
The web app also has a visual token editor at /k/[id]/edit, and I swapped next dev for a custom server.js that mounts Socket.IO next to Next.js. It relays two events, cursor-move and token-update, so two people on the same key see each other's cursors and edits. It's a broadcast relay with no conflict resolution, but it was a fun hour.
Getting keys into the editor
A key is only useful if it reaches the place where code gets generated. There are two consumers.
An interactive inquirer menu: fetch a key by id, or list local keys. Fetched keys are saved to .uikey/<id>.md in the current directory and summarised in a cli-table3 table. docs/install.sh clones the repo and npm links it, because it isn't on npm yet.
One command, uikey.applyStyle. It asks for an id, writes .uikey/context.md, and appends a line to .cursorrules telling the agent to follow it. With no workspace open it falls back to inserting the key as a comment at the top of the active file.
The .cursorrules injection is the bit that made it click for me. Instead of pasting a key into every chat, the rule makes the editor's agent pick up the design system on every request without being asked.
The generator fan-out
On day two I got ambitious and scaffolded ten generator packages that take a manifest and emit framework code: Tailwind config, shadcn, Next.js, React Native, Flutter, SwiftUI, Vue SFC, Svelte, Storybook and Framer. Some are real. The Tailwind generator, for example, resolves W3C-style $value token objects and writes a full theme.extend block. The SwiftUI one maps layout types to VStack/HStack/ZStack. Others are thin: the Framer generator is a seven-line function that wraps the tree in a clipboard envelope.
What broke, and what is still a stub
Writing this up meant re-reading the code with fresh eyes. Here's the honest list:
- The hot path skips the compiler. The extension sends the raw
extractDOMJSON to/api/ingest, and/api/k/[id]returns that JSON in a code fence. A comment in the route literally says it would callgenerateAIPrompt"if we have" it. The compiler and formatter are built and tested; wiring them into the API is a few lines I haven't written. - Only four style properties are captured. Background colour, colour, padding and font size. No margins, gaps, flex/grid direction, border radius, shadows or font family. Geometry partly makes up for it, but a real key needs at least layout mode and radius.
- Contrast checks are naive.
checkContrastcompares an element's owncolorwith its ownbackgroundColor. Most text sits on a transparent element whose visible background comes from an ancestor, and a fully transparent colour is treated as a pass. A correct check has to walk up to the first opaque background. - "Repairs" leak into the output. When the audit finds a missing
altit writesalt="Repaired: missing alt"into the node. That makes the issue visible, but a model will happily copy that string into generated code. - Animations and themes are placeholders.
extractAnimationslooks fortransitionandanimationon the styles object, but those properties are never captured, so it never fires.extractThemeloops over:rootrules and doesn't extract anything yet. - The vision package is a mock.
packages/visionlaunches Puppeteer and takes a full-page screenshot, but the per-component "slices" are placeholder strings, not cropped images. - Hardcoded hosts. The extension and VS Code extension talk to
localhost:3000; the CLI talks touikey.devand falls back to mock data if the fetch fails.
None of that is surprising for a two-day build, but I'd rather list it than have someone find it.
What's next
The order I'd tackle it in:
- Run
sanitizeDOMTree,generateDesignTokensandgenerateAIPromptinside/api/k/[id]so every key is served as the compact prompt. - Capture layout properties (
display,flex-direction,gap,border-radius,font-family) and resolve contrast against the nearest opaque ancestor. - Define one manifest schema and an adapter from extracted trees to it, so the ten generators run on real pages.
- Add role-based token naming using the geometry that's already captured.
The core idea held up: extract what the browser rendered, not what the developer wrote, then compress it into a form the model already knows how to read. The rest is plumbing.
Extract any website's computed styles and layout into a compact UI manifest for AI code generators.
git clone https://github.com/shubhrgunjan/UIKey