Y
Yash B
Guest
A technical walkthrough of UiUX Report: a pure-function rule engine, WCAG contrast resolution, perceptual colour clustering, a pluggable design-system abstraction, and an SSRF-hardened Playwright backend, built by a designer who wrote the specs while an AI coding agent wrote the code.
This is the technical companion to my first article, which covers the "why" and the product story. Here I go under the hood: the architecture, the algorithms, the decisions I got wrong first, and how the system was actually built.
Design system compliance review is a manual diff between two datasets: the set of values used in a screen (fills, type styles, spacing, radii, component instances) and the set of values permitted by the design system. Humans perform this diff by eye, layer by layer, and then communicate the result through comments and tickets.
Most of that diff is mechanical and therefore automatable. A fill of
So the engineering framing is: extract facts from a design file, load a reference token set, run deterministic predicates over the facts, aggregate the violations into a score. Existing Figma linters do this well when the design system already lives inside Figma as variables and styles. I wanted something that worked when the reference lived somewhere else: on a documentation website, in a JSON file, or implicitly inside the team's best screens. That requirement shaped everything below.
I wrote these down before any code existed, and every architectural choice traces back to one of them:
That last constraint turned out to be a forcing function for good architecture, which I come back to in section 15.
UiUX Report is a TypeScript monorepo with three packages:
Package 1: the Figma plugin. Scans a selection, evaluates rules, computes scores, and hosts the UI.
Package 2:
Package 3: the backend analyzer. A job-based HTTP service that renders design-system documentation sites in a real browser and extracts tokens from them. It is optional; the plugin is fully functional without it.

A Figma plugin is not one program. It is two JavaScript realms that cannot touch each other's globals:
They communicate exclusively through serialised messages (
ts
Two consequences of this model are worth stating plainly, because they drove real design decisions:
File downloads (the JSON export) also happen in the UI realm, via a
The most consequential architectural decision was to confine every touch of the
ts
The walker normalises Figma's idiosyncrasies into one consistent form: line height arrives as
Everything downstream of the walker (rules, scoring, token lookup, clustering) is a pure function of
My first brief told the build agent to evaluate against IBM Carbon. What I meant was that the plugin's own UI should look like Carbon. What I wrote was ambiguous, and the agent faithfully hard-wired Carbon as the reference for every scan. It was a spec bug, not a code bug, and it surfaced only because I reviewed the architecture plan. The fix generalised the design:
ts
Carbon became one provider among many. Adding a design system is now a data change (one generated token file plus one registry entry) rather than a code change.
For the ten built-in systems, token values are generated from the systems' own published packages at build time, not hand-typed. For Carbon, a codegen script (run explicitly via
I deliberately separated design values (Carbon says Blue 60 is
All three produce the same
Mode A: named design system. A provider supplies the tokens. Reference-based rules run (palette compliance, type-token match, spacing-scale membership).
Mode B: no design system. There is no reference, so the selection is evaluated against itself. Reference-dependent rules are disabled by a mode policy, and consistency rules replace them: colour clustering, spacing dispersion, font-family count, type-size scale detection, radius dominance, icon-size snapping.
Mode C: learn from screens. Mode B's analysis, persisted. The user selects approved reference frames; the plugin runs the same clustering and dominant-value extraction, shows a review sheet, and saves the result as a named custom provider in
The insight that made this cheap: "no design system" and "learn a design system" are the same computation with different lifecycles. One analysis, either discarded after a scan or saved as a reference.
Accessibility rules are exempt from mode policy and run in every mode. WCAG thresholds are not a design-system preference.
Each rule is a module implementing one interface:
ts
The engine's job is small: read the merged rule configuration, skip disabled rules and rules excluded by the current mode, look up each
Every threshold is read from
Adding a rule means adding one module file and one config entry. Nothing else in the engine changes, which is what made the later custom-rule feature a single additive dispatch branch (section 9).
WCAG 2.1 defines contrast ratio from relative luminance:
Thresholds: 4.5:1 for normal text, 3:1 for large text (24px regular, or roughly 18.5px bold) and for meaningful non-text UI components (SC 1.4.11). Black on white yields exactly 21:1, which doubles as a sanity-check test.
The formula is the easy half. The hard half is: what colour is actually behind this text? A text layer's own fill tells you nothing about its background. In Figma, the background is whatever is painted beneath the text's bounding box: a sibling rectangle lower in z-order, a parent frame's fill, or several semi-transparent layers stacked.
The effective-background resolver works as follows:
The honest limitation is that blend modes, layer effects, and partial overlaps are not modelled. The resolver is a deliberate approximation that is correct for the common case (solid fills in nested frames) and explicit about the cases it can't resolve.
"Is this hex close to a token?" is a perceptual question, and Euclidean distance in RGB is a poor answer to it. The plugin converts to CIELAB and uses CIE76 ΔE, the Euclidean distance in L*a*b*:
A ΔE below roughly 5 is a "same colour to a casual observer" band, which is exactly the near-miss pattern I wanted to catch:
Two rules would otherwise fire on the same fill: C1 (no palette match) and C2 (near miss). A single shared
On the choice of ΔE76: CIEDE2000 is more perceptually uniform, particularly in saturated blues, and is the better metric if you need precision. For near-duplicate detection, where you only need to know whether two colours are practically indistinguishable, the simpler formula is adequate and far easier to verify by hand and in tests. It's a trade-off I'd revisit if false positives show up on heavily saturated palettes.
Each category starts at 10 and loses points per violation, weighted by severity, with a normalisation so that large screens aren't punished merely for containing more layers:
Default weights: accessibility 30%, colour 20%, typography 20%, spacing 15%, components 15%. Both the severity coefficients and the weights live in config, and weights are re-normalised if the user edits them so they always sum to 1.
Two honest design notes. First, the
Consistency analysis rests on one idea: in a well-made file, the values that dominate by frequency are the intended ones, and rarely-used values near a dominant one are probably mistakes.
For colour:
Spacing follows the same shape: gather auto-layout gaps and paddings, flag values used once or twice when a value within ±2px is used many times, and flag total distinct values above a threshold (default 8). Radius, icon size, shadow parameters, and interactive-element height use the same dominant-versus-minority logic with different tolerances.
Type hierarchy is checked differently: identify the dominant body size, then verify that at least one heading size exceeds it by a configurable ratio (default 1.25×). A frame whose largest text is barely bigger than its body text has no discernible hierarchy.
In Mode C, this output is not just reported. It is reified into a
I wanted designers to add rules, but a rule is code. Rather than invent a scripting language (which would have meant parser, sandboxing, and error-reporting concerns far beyond the project's scope), I used parameterised templates: a small set of generic rule modules whose behaviour is fully determined by user-filled parameters.
The eight shipped templates are
json
Each template declares a parameter schema (so the UI can render the correct inputs) and a sentence pattern (so the rule description is generated live: "corner radius must be one of 0, 4, 8"). A property accessor registry maps names like
Integration cost was minimal by design.
Built-in rules can be toggled and tuned but not deleted, which guarantees that "reset to defaults" always has a baseline to return to. Custom rules can be created, edited, and deleted, and the whole customised rule set exports to a single JSON bundle for distribution across a team.
Configuration resolves in three layers, merged at scan time:
It accepts three families of input:
and performs three stages:
Because it performs no I/O (no
The first importer implementation fetched a pasted URL directly from the plugin UI and scraped CSS custom properties from the response. In manual QA it failed on the exact class of site I cared about, for two reasons that no amount of plugin-side cleverness could fix:
Both problems disappear on a server that controls its own headers and can run a real browser. That was the moment the project grew its third package.
The service is Node, Fastify, and Playwright, exposing a job-based API because analysis takes tens of seconds:

Rendering matters. A static fetch cannot see CSS-in-JS or styles injected at runtime. By loading the page in Chromium and reading the computed custom properties from the live DOM, the extractor sees what a user's browser sees.
Crawling is heuristic and bounded. Same-origin links are scored by how documentation-like they look: path and anchor-text matches for
Reconciliation policy is explicit. Deterministic extraction always wins a conflict, because its provenance is inspectable. AI-sourced items are included only above a confidence threshold, tagged with a source page, and surfaced with a confidence band (high, medium, low) on the review sheet. The AI pass is dormant unless an API key is configured, and the whole pipeline remains useful without it.
The AI pass is constrained. The model receives screenshots of rendered pages plus the deterministic findings, and is asked, through a structured tool call with a fixed JSON schema, to find tokens that were visually presented but absent from the CSS (swatches in a table, values in prose). Constraining output to a schema, rather than free text, is what makes the response machine-mergeable and rejectable.
A service that fetches arbitrary user-supplied URLs is, by construction, a server-side request forgery primitive. Left unguarded, it could be pointed at
One residual risk I'd flag for anyone building something similar: DNS rebinding, where a hostname resolves to a public address at validation time and a private one at connection time, is the classic bypass of validate-then-connect designs. Request-level interception narrows the window, and pinning the validated address for the connection closes it more fully. It belongs on any hardening checklist.
The first deployment plan targeted a host that required a credit card, which violated the free-tier-only rule. The agent re-planned onto Render's free web service with a
The UI polls
Before any network call, the plugin checks the pasted URL against a client-side table of curated providers. A match on a known host (for example the Carbon site) short-circuits to the bundled token set: zero network, zero cost, instant.
The import dialog has four explicit result states, because a tool for non-engineers must never fail silently or with a raw error:
If the backend is entirely unreachable, the plugin falls back to a direct client-side fetch (static token files only) and says so.
Every path ends at the same review sheet: every extracted token grouped by type, per-item confidence badges, individual removal, a name field, and a save action. Imported systems, learned systems, and AI-assisted systems are stored identically in
The suite stood at 237 passing tests at the v1.2 milestone, all run with Vitest against the pure core:
What this suite cannot prove is the contract with the live Figma API: that the tree walker's assumptions about node shapes match real files, that
The importer failure I mentioned earlier was caught exactly this way. The unit tests passed because they simulated fetching; reality did not behave like the simulation. That's the cleanest argument I know for keeping a human in the loop even when the test suite is green.
I have no in depth programming background. The project was built by writing specifications and reviewing plans while Claude Code implemented them. The loop that worked:
Three course corrections shaped the final product:
Being precise about limits is part of shipping a measuring tool:
Next on the list: tightening manifest network permissions to the minimum set before any Community submission, a side-by-side A/B scoring view, flow-level (multi-screen) evaluation, and score history per file.
The most useful thing I learned is that a spec-driven workflow rewards exactly the skills design systems people already have: defining a canonical model, writing unambiguous rules, thinking about failure states, and insisting on a review gate. The syntax turned out to be the least interesting part of the system. The architecture, the reconciliation policy, and the question "what must a human approve before this becomes truth?" were the real work.
If you're building developer or design tooling and have war stories about Figma's plugin sandbox, SSRF hardening, or perceptual colour metrics, I'd genuinely like to hear them in the comments.
This is the technical companion to my first article, which covers the "why" and the product story. Here I go under the hood: the architecture, the algorithms, the decisions I got wrong first, and how the system was actually built.
1. The problem, restated as an engineering problem
Design system compliance review is a manual diff between two datasets: the set of values used in a screen (fills, type styles, spacing, radii, component instances) and the set of values permitted by the design system. Humans perform this diff by eye, layer by layer, and then communicate the result through comments and tickets.
Most of that diff is mechanical and therefore automatable. A fill of
#0F62F0 where the token is #0F62FE is a lookup failure. A 13px auto-layout gap on a 4px/8px spacing scale is a set-membership failure. Text at 4.2:1 contrast where WCAG 2.1 SC 1.4.3 requires 4.5:1 is an arithmetic comparison.So the engineering framing is: extract facts from a design file, load a reference token set, run deterministic predicates over the facts, aggregate the violations into a score. Existing Figma linters do this well when the design system already lives inside Figma as variables and styles. I wanted something that worked when the reference lived somewhere else: on a documentation website, in a JSON file, or implicitly inside the team's best screens. That requirement shaped everything below.
2. Constraints that shaped every decision
I wrote these down before any code existed, and every architectural choice traces back to one of them:
Designs must not leave the user's machine. Teams work under NDA on unreleased products. The core evaluation path therefore has zero network dependency.
The reference design system must be swappable. Not every team uses Carbon, and some use nothing.
Everything is editable by non-engineers. Thresholds, severities, weights, and custom rules must be changeable without rebuilding the plugin.
Nothing becomes reference data without human approval. Extracted or inferred tokens are proposals, not facts.
Free-tier infrastructure only. If it can't run at zero cost, it ships as optional.
The builder is not a programmer. The codebase must be structured so that a non-engineer can review plans and verify behaviour through tests and manual QA, rather than reading implementation code.
That last constraint turned out to be a forcing function for good architecture, which I come back to in section 15.
3. System overview
UiUX Report is a TypeScript monorepo with three packages:
Package 1: the Figma plugin. Scans a selection, evaluates rules, computes scores, and hosts the UI.
Package 2:
@uiux-report/importer. A pure TypeScript library that normalises heterogeneous token sources into one canonical schema. It performs no I/O, which is what lets both the plugin and the backend consume it.
Package 3: the backend analyzer. A job-based HTTP service that renders design-system documentation sites in a real browser and extracts tokens from them. It is optional; the plugin is fully functional without it.

4. Figma's two-realm execution model
A Figma plugin is not one program. It is two JavaScript realms that cannot touch each other's globals:
- The main thread runs in a sandbox with access to the Figma scene API (
figma.currentPage.selection, node properties,figma.viewport,figma.clientStorage). It has no DOM. In this design I also treat it as having no network access. - The UI iframe is an ordinary browser context with DOM and
fetch(), but no access to the scene graph.
They communicate exclusively through serialised messages (
figma.ui.postMessage and parent.postMessage). I modelled this boundary as a discriminated-union message contract so that the compiler enforces both sides:ts
Code:
// Simplified: the shape of the contract
type UIToMain =
| { type: 'run-scan' }
| { type: 'rescan' }
| { type: 'select-layer'; layerId: string }
| { type: 'save-design-system'; payload: DesignSystemRecord }
| { type: 'export-json' };
type MainToUI =
| { type: 'scan-started' }
| { type: 'scan-results'; result: ScanResult; scores: ScoreSet }
| { type: 'scan-error'; message: string }
| { type: 'export-ready'; json: string; filename: string };
Two consequences of this model are worth stating plainly, because they drove real design decisions:
- Network access lives only in the UI iframe. All backend calls originate there. The plugin declares permitted domains in
manifest.jsonundernetworkAccess, with a mandatoryreasoningstring. Because the iframe's origin isnull, any server it calls must answer with permissive CORS headers. This is a hard constraint on the backend, and it is also why many documentation sites can't be read directly from the plugin. - The main thread stays reactive and stateless between messages. It re-reads
figma.currentPage.selectionfresh on every scan rather than caching it, which eliminates a whole class of stale-selection bugs.
File downloads (the JSON export) also happen in the UI realm, via a
Blob plus an anchor element, because the main-thread sandbox has no DOM.5. The "Figma seam": from live scene graph to plain data
The most consequential architectural decision was to confine every touch of the
figma.* global to a single module, the tree walker, and have it emit a plain, serialisable snapshot:ts
Code:
// Simplified
interface NodeSnapshot {
id: string;
name: string;
type: 'TEXT' | 'FRAME' | 'RECTANGLE' | 'INSTANCE' | /* ... */;
parentId: string | null;
childIds: string[];
bounds: { x: number; y: number; width: number; height: number };
fills: PaintSnapshot[];
strokes: PaintSnapshot[];
cornerRadius?: number;
opacity: number;
effects: EffectSnapshot[];
layout?: { gap: number; padding: [number, number, number, number] };
text?: { fontFamily: string; fontSize: number; fontWeight: number;
lineHeightPx: number; characters: string };
component?: { isInstance: boolean; mainComponentId?: string };
}
type ScanGraph = Map<string, NodeSnapshot>;
The walker normalises Figma's idiosyncrasies into one consistent form: line height arrives as
PIXELS, PERCENT, or AUTOand becomes pixels; font style strings like "Semi Bold" become numeric weights; paint objects become typed solid/gradient/image variants with explicit alpha.Everything downstream of the walker (rules, scoring, token lookup, clustering) is a pure function of
ScanGraph and configuration. This is the classic functional core, imperative shell pattern, and it bought three concrete things:- Unit-testability outside Figma. Rules are tested against hand-built fake graphs in Vitest, with no Figma runtime.
- Portability. The same rule engine can run in a future web app against a graph built from a Figma REST API response, with no changes.
- Reviewability. A non-programmer can read a test named
flags 13px gap when spacing scale is 4,8,12,16and understand what the system guarantees.
6. Design-system-agnostic evaluation: tokens, providers, and three modes
A mistake worth admitting
My first brief told the build agent to evaluate against IBM Carbon. What I meant was that the plugin's own UI should look like Carbon. What I wrote was ambiguous, and the agent faithfully hard-wired Carbon as the reference for every scan. It was a spec bug, not a code bug, and it surfaced only because I reviewed the architecture plan. The fix generalised the design:
ts
Code:
// The rule engine never knows which design system it is checking
interface DesignSystemTokens {
colors: PaletteEntry[]; // { name, hex }
spacing: number[]; // px values, e.g. [2,4,8,12,16,24,32,...]
radii: number[];
fontFamilies: string[];
typeScale: TypeToken[]; // { name, size, weight, lineHeight, letterSpacing }
iconSizes: number[];
themes?: ThemeTokenMap[];
}
interface DesignSystemProvider {
id: string;
label: string;
getTokens(): DesignSystemTokens;
}
Carbon became one provider among many. Adding a design system is now a data change (one generated token file plus one registry entry) rather than a code change.
Where token values come from
For the ten built-in systems, token values are generated from the systems' own published packages at build time, not hand-typed. For Carbon, a codegen script (run explicitly via
npm run generate-tokens and committed to git like a lockfile) imports @carbon/colors, @carbon/themes, @carbon/type, and @carbon/layout, flattens them, converts rem to px (base 16), and writes a typed constants file. When IBM updates Carbon, a version bump plus one command refreshes the reference.I deliberately separated design values (Carbon says Blue 60 is
#0F62FE) from policy values (my team considers a colour within ΔE 5 of a token a "near miss"). Policy values live in rules.json, not in generated token files, because they are opinions the user should be able to change.The three evaluation modes
All three produce the same
DesignSystemTokens shape, so the rule engine is oblivious to which one is active:Mode A: named design system. A provider supplies the tokens. Reference-based rules run (palette compliance, type-token match, spacing-scale membership).
Mode B: no design system. There is no reference, so the selection is evaluated against itself. Reference-dependent rules are disabled by a mode policy, and consistency rules replace them: colour clustering, spacing dispersion, font-family count, type-size scale detection, radius dominance, icon-size snapping.
Mode C: learn from screens. Mode B's analysis, persisted. The user selects approved reference frames; the plugin runs the same clustering and dominant-value extraction, shows a review sheet, and saves the result as a named custom provider in
figma.clientStorage. From then on it behaves exactly like a Mode A provider.The insight that made this cheap: "no design system" and "learn a design system" are the same computation with different lifecycles. One analysis, either discarded after a scan or saved as a reference.
Accessibility rules are exempt from mode policy and run in every mode. WCAG thresholds are not a design-system preference.
7. The rule engine contract
Each rule is a module implementing one interface:
ts
Code:
interface RuleModule {
id: string; // e.g. "A1", "C2", "T5"
evaluate(ctx: RuleContext): Violation[];
eligibleNodeIds(graph: ScanGraph): string[]; // denominator for scoring
}
interface RuleContext {
graph: ScanGraph;
tokens: DesignSystemTokens;
params: Record<string, unknown>; // from the rule's config entry
mode: EvaluationMode;
}
interface Violation {
ruleId: string;
category: Category;
severity: 'critical' | 'major' | 'minor';
layerId: string;
layerName: string;
message: string;
found: string;
expected: string;
}
The engine's job is small: read the merged rule configuration, skip disabled rules and rules excluded by the current mode, look up each
RuleModule by id, call evaluate, collect violations, and call eligibleNodeIds to build per-category eligible-layer counts for scoring.Every threshold is read from
params, never hardcoded. A rule that checks minimum touch-target size reads params.criticalBelow and params.majorBelow, so editing the number in the Rules tab changes behaviour without a rebuild. This is the property that makes the notes-style rules editor possible at all.Adding a rule means adding one module file and one config entry. Nothing else in the engine changes, which is what made the later custom-rule feature a single additive dispatch branch (section 9).
8. The algorithms
8.1 Contrast, and the problem of the effective background
WCAG 2.1 defines contrast ratio from relative luminance:
Code:
L = 0.2126·R + 0.7152·G + 0.0722·B (R, G, B linearised)
linearise(c) = c / 12.92 if c ≤ 0.04045
= ((c + 0.055) / 1.055) ^ 2.4 otherwise (c in 0..1)
contrast = (L_lighter + 0.05) / (L_darker + 0.05)
Thresholds: 4.5:1 for normal text, 3:1 for large text (24px regular, or roughly 18.5px bold) and for meaningful non-text UI components (SC 1.4.11). Black on white yields exactly 21:1, which doubles as a sanity-check test.
The formula is the easy half. The hard half is: what colour is actually behind this text? A text layer's own fill tells you nothing about its background. In Figma, the background is whatever is painted beneath the text's bounding box: a sibling rectangle lower in z-order, a parent frame's fill, or several semi-transparent layers stacked.
The effective-background resolver works as follows:
- Take the text node's bounding box.
- Walk down the z-order among siblings (children are ordered back-to-front), looking for the nearest layer whose fill is visible and whose bounds fully cover the text box.
- If none is found, walk up the ancestor chain, applying the same test to each parent's own fills.
- When a covering layer has alpha below 1, composite it over whatever lies beneath, recursively:
Code:
C_out = α · C_fg + (1 − α) · C_bg
- If the nearest covering fill is an image or gradient, the background is not a single colour. The resolver returns unresolvable rather than guessing, and the text is handed to a separate rule (A5, "text over image without scrim") which flags the risk instead of computing a ratio.
- If the walk reaches the canvas with nothing found, it falls back to white and marks the result
confidence: 'low'.
The honest limitation is that blend modes, layer effects, and partial overlaps are not modelled. The resolver is a deliberate approximation that is correct for the common case (solid fills in nested frames) and explicit about the cases it can't resolve.
8.2 Colour distance: near-miss detection
"Is this hex close to a token?" is a perceptual question, and Euclidean distance in RGB is a poor answer to it. The plugin converts to CIELAB and uses CIE76 ΔE, the Euclidean distance in L*a*b*:
Code:
sRGB → linear RGB → XYZ (D65 white point) → L*a*b*
ΔE76 = √( (L₂ − L₁)² + (a₂ − a₁)² + (b₂ − b₁)² )
A ΔE below roughly 5 is a "same colour to a casual observer" band, which is exactly the near-miss pattern I wanted to catch:
#0F62F0 used where #0F62FE is the token. A second, simpler metric (maximum per-channel RGB difference ≤ 8) implements a cruder near-miss rule. The two are exposed as separate thresholds in config.Two rules would otherwise fire on the same fill: C1 (no palette match) and C2 (near miss). A single shared
classifyColorMatch() returns exactly one of exact | near | none, so one fill produces one violation, never two.On the choice of ΔE76: CIEDE2000 is more perceptually uniform, particularly in saturated blues, and is the better metric if you need precision. For near-duplicate detection, where you only need to know whether two colours are practically indistinguishable, the simpler formula is adequate and far easier to verify by hand and in tests. It's a trade-off I'd revisit if false positives show up on heavily saturated palettes.
8.3 Scoring
Each category starts at 10 and loses points per violation, weighted by severity, with a normalisation so that large screens aren't punished merely for containing more layers:
Code:
penalty = 2.0·critical + 1.0·major + 0.25·minor
divisor = max(1, eligibleLayers / 25)
score = clamp( 10 − penalty / divisor , 1 , 10 ) // rounded to 1 decimal
overall = Σ (weight_c · score_c) // weights sum to 1.0
Default weights: accessibility 30%, colour 20%, typography 20%, spacing 15%, components 15%. Both the severity coefficients and the weights live in config, and weights are re-normalised if the user edits them so they always sum to 1.
Two honest design notes. First, the
eligibleLayers / 25 divisor is a pragmatic heuristic, not a statistically derived constant; it exists so a 300-layer dashboard and a 30-layer card are comparable. Second, when rules are disabled, the category score carries an "(n rules off)" annotation. A 9.8 with half the rules switched off is a different claim from a 9.8 with everything on, and the UI shouldn't let that distinction disappear.8.4 Inference for Modes B and C
Consistency analysis rests on one idea: in a well-made file, the values that dominate by frequency are the intended ones, and rarely-used values near a dominant one are probably mistakes.
For colour:
- Collect every fill and stroke colour with a usage count.
- Greedy-cluster by ΔE < 5, seeding each cluster with the most frequently used colour.
- Within each cluster, the seed is the dominant value and the remainder are drift candidates.
- Flag minority members, suggesting the dominant colour. The example from the first article is real behaviour:
#F4F4F5used 47 times and#F5F5F5used twice yields two flagged layers. - Separately flag screens whose total cluster count exceeds a configurable ceiling (default 12).
Spacing follows the same shape: gather auto-layout gaps and paddings, flag values used once or twice when a value within ±2px is used many times, and flag total distinct values above a threshold (default 8). Radius, icon size, shadow parameters, and interactive-element height use the same dominant-versus-minority logic with different tolerances.
Type hierarchy is checked differently: identify the dominant body size, then verify that at least one heading size exceeds it by a configurable ratio (default 1.25×). A frame whose largest text is barely bigger than its body text has no discernible hierarchy.
In Mode C, this output is not just reported. It is reified into a
DesignSystemTokens object and presented on the review sheet: "Found 9 colours, 6 spacing values, 2 fonts, 5 type sizes."9. User-defined rules without a DSL
I wanted designers to add rules, but a rule is code. Rather than invent a scripting language (which would have meant parser, sandboxing, and error-reporting concerns far beyond the project's scope), I used parameterised templates: a small set of generic rule modules whose behaviour is fully determined by user-filled parameters.
The eight shipped templates are
allowed-values, max-distinct, min-value/max-value, name-pattern-must-be-instance, required-sibling, max-children, and ratio-between. A custom rule is just data:json
Code:
{
"id": "custom-radius-check",
"templateId": "allowed-values",
"category": "spacing",
"severity": "major",
"enabled": true,
"params": { "property": "cornerRadius", "values": [0, 4, 8] }
}
Each template declares a parameter schema (so the UI can render the correct inputs) and a sentence pattern (so the rule description is generated live: "corner radius must be one of 0, 4, 8"). A property accessor registry maps names like
cornerRadius, fontSize, gap, and opacity onto NodeSnapshot fields, so adding a checkable property is one registry entry.Integration cost was minimal by design.
RuleDefinition gained an optional templateId, and the engine gained one additive dispatch branch: if a rule has a templateId, route to the template module with the rule's params; otherwise look up by id as before. Scoring, mode policy, and config merging were untouched. Where I want a genuinely new kind of check, I add a template. Where a team wants only different numbers, they edit parameters.Built-in rules can be toggled and tuned but not deleted, which guarantees that "reset to defaults" always has a baseline to return to. Custom rules can be created, edited, and deleted, and the whole customised rule set exports to a single JSON bundle for distribution across a team.
10. Configuration layering and persistence
Configuration resolves in three layers, merged at scan time:
Code:
shipped defaults (rules.json, bundled into code.js)
▼ overlaid by
user overrides (toggles, severities, params, weights) ← figma.clientStorage
▼ plus
custom rules (template instances) ← figma.clientStorage
rules.json is never mutated at runtime; it is the immutable baseline. User state is stored as a diff against defaults, so shipping a new default in a plugin update doesn't silently revert a user's customisations, and "reset to defaults" is simply deleting the diff.figma.clientStorage is an asynchronous, per-user, per-plugin key-value store with a modest total size cap (on the order of 1 MB), which is a real constraint for learned design systems. It's also why token sets are stored compactly, as arrays of values rather than verbose objects.11. The shared importer package
@uiux-report/importer is the normalisation layer, and the reason it is a separate package rather than plugin code is simple: both the plugin and the backend must parse tokens identically. If the plugin's idea of "a spacing value" ever diverged from the backend's, the same website would yield different design systems depending on the path it took.It accepts three families of input:
- W3C Design Tokens format (
$value,$type, nested groups). - Style Dictionary output, including its nested
valueandattributesconventions. - CSS custom properties, such as
--color-primary: #0f62fe.
and performs three stages:
- Parse. Format auto-detection, followed by flattening into raw
{ name, rawValue }pairs. - Classify. Each raw value is assigned a bucket (
color,spacing,radius,font-size,font-weight,font-family) by combining value shape (a hex string is a colour; a unitful number is a dimension) with name heuristics (--space-4versus--radius-md,--font-size-lg). Value shape wins when the two disagree. - Normalise. rem and em to px, colour strings to canonical hex, deduplication, and sorting, producing a review-ready summary with counts per bucket.
Because it performs no I/O (no
fetch, no filesystem), it is trivially testable with fixture strings, and it runs unchanged in the Figma sandbox, in Node, and in a browser.12. The backend analyzer
Why a backend was unavoidable
The first importer implementation fetched a pasted URL directly from the plugin UI and scraped CSS custom properties from the response. In manual QA it failed on the exact class of site I cared about, for two reasons that no amount of plugin-side cleverness could fix:
- CORS. The plugin iframe has a
nullorigin. Most sites don't send CORS headers permitting it, so the browser blocks the response. - Client-side rendering. Documentation sites built with Storybook and similar tools return an almost empty HTML shell; the content is painted by JavaScript after load. A plain
fetchsees the shell, not the page.
Both problems disappear on a server that controls its own headers and can run a real browser. That was the moment the project grew its third package.
Pipeline
The service is Node, Fastify, and Playwright, exposing a job-based API because analysis takes tens of seconds:
Code:
POST /analyze { url } → 202 { jobId }
GET /jobs/:id → { status, stage, progress, result? }

Rendering matters. A static fetch cannot see CSS-in-JS or styles injected at runtime. By loading the page in Chromium and reading the computed custom properties from the live DOM, the extractor sees what a user's browser sees.
Crawling is heuristic and bounded. Same-origin links are scored by how documentation-like they look: path and anchor-text matches for
tokens, colors, typography, spacing, foundations, elevation, with penalties for depth and for obviously non-doc pages. Only the top N are rendered, capped for both cost and politeness.Reconciliation policy is explicit. Deterministic extraction always wins a conflict, because its provenance is inspectable. AI-sourced items are included only above a confidence threshold, tagged with a source page, and surfaced with a confidence band (high, medium, low) on the review sheet. The AI pass is dormant unless an API key is configured, and the whole pipeline remains useful without it.
The AI pass is constrained. The model receives screenshots of rendered pages plus the deterministic findings, and is asked, through a structured tool call with a fixed JSON schema, to find tokens that were visually presented but absent from the CSS (swatches in a table, values in prose). Constraining output to a schema, rather than free text, is what makes the response machine-mergeable and rejectable.
Security: SSRF
A service that fetches arbitrary user-supplied URLs is, by construction, a server-side request forgery primitive. Left unguarded, it could be pointed at
http://localhost, internal network hosts, or cloud metadata endpoints such as 169.254.169.254. Controls:- Scheme allow-list:
httpandhttpsonly. - Public-IP-only validation. The hostname is resolved and every resolved address is checked against private, loopback, link-local, and metadata ranges before connecting.
- Redirect re-validation. Every redirect hop goes through the same check, so a public URL can't 302 into an internal one.
- Browser-level interception. Validating the top-level URL is not enough, because a rendered page can itself request internal resources (images, scripts, XHR). Playwright's request interception validates every sub-request the page makes against the same policy.
- Per-IP rate limiting, size limits on fetched content, and page and token caps.
One residual risk I'd flag for anyone building something similar: DNS rebinding, where a hostname resolves to a public address at validation time and a private one at connection time, is the classic bypass of validate-then-connect designs. Request-level interception narrows the window, and pinning the validated address for the connection closes it more fully. It belongs on any hardening checklist.
Hosting, under a zero-cost constraint
The first deployment plan targeted a host that required a credit card, which violated the free-tier-only rule. The agent re-planned onto Render's free web service with a
render.yaml blueprint, an in-memory cache and job store instead of Redis, and the Chromium flag --disable-dev-shm-usage for constrained shared-memory environments. The trade-offs are real and documented: the free instance sleeps after inactivity (the first request after a quiet spell pays a cold start of tens of seconds), and the in-memory cache resets on restart. The plugin's progress copy was adjusted so a cold start reads as "waking the analyzer" rather than a hang.13. Plugin-to-backend protocol and failure UX
The UI polls
GET /jobs/:id roughly every 1.5 seconds and renders real pipeline stages ("Rendering the page…", "Reading design tokens…", "Page 3 of 8"), not a fake progress bar. The stage strings come from the backend's actual state machine.Before any network call, the plugin checks the pasted URL against a client-side table of curated providers. A match on a known host (for example the Carbon site) short-circuits to the bundled token set: zero network, zero cost, instant.
The import dialog has four explicit result states, because a tool for non-engineers must never fail silently or with a raw error:
- Analysing, with live stage text.
- Success, showing counts found and a Review and save action.
- Partial, with counts and a choice between saving what was found or learning the remainder from screens.
- Failure, with a plain-language reason (JS-rendered page, site blocked access, no token data found) and a one-click jump to Mode C.
If the backend is entirely unreachable, the plugin falls back to a direct client-side fetch (static token files only) and says so.
Every path ends at the same review sheet: every extracted token grouped by type, per-item confidence badges, individual removal, a name field, and a save action. Imported systems, learned systems, and AI-assisted systems are stored identically in
clientStorage, so there is one storage path and one deletion path to reason about. The review gate is structural: there is no code path from "extracted" to "saved" that bypasses it.14. Testing strategy, and what tests cannot prove
The suite stood at 237 passing tests at the v1.2 milestone, all run with Vitest against the pure core:
- Scoring: hand-built violation arrays against expected scores, including the normalisation divisor and the floor at 1.
- Colour math: known reference pairs (black on white is 21:1), Lab conversion round-trips, ΔE boundary cases.
- Token lookup: nearest-match behaviour at exact and boundary distances.
- One test file per rule: a compliant fixture must yield zero violations, and each failure mode must yield exactly one violation with the expected fields.
- Templates: compliant and violating fixtures per template, plus a persistence round-trip (create, reload, delete).
- Importer: fixture W3C, Style Dictionary, and CSS inputs mapped to expected token sets.
- Backend: extractor merging, schema validation, SSRF guards, and cache behaviour, with the Claude call mocked.
What this suite cannot prove is the contract with the live Figma API: that the tree walker's assumptions about node shapes match real files, that
figma.viewport zoom lands on the right layer, that the postMessage bridge behaves in the real app, and that a cross-origin fetch from the iframe succeeds under the real manifest permissions. The agent said so itself in each hand-off ("what I can't verify from here"), and each time the gap was closed by a manual test pass in Figma desktop: a clean Carbon-based frame (expect high scores), a deliberately broken frame (expect each category to light up), and each importer failure state.The importer failure I mentioned earlier was caught exactly this way. The unit tests passed because they simulated fetching; reality did not behave like the simulation. That's the cleanest argument I know for keeping a human in the loop even when the test suite is green.
15. How it was built: spec-driven development with an AI agent
I have no in depth programming background. The project was built by writing specifications and reviewing plans while Claude Code implemented them. The loop that worked:
- Write the spec in domain language. Not "implement a luminance function" but "text must meet 4.5:1 against its effective background, found by walking the layers beneath it". The rule definitions, severities, and scoring rubric were mine, because that is the expertise I actually have.
- Demand a plan before code. Every change began with "show me the updated plan before writing anything." I then read the plan for architecture and behaviour, never syntax. This is where the Carbon mix-up was caught, before it was entrenched in 20 rule modules.
- Review at the seam, not the implementation. The functional-core architecture meant I could validate behaviour by reading test names and fixtures, and judge structure by reading the module map.
- Sequence for verifiability. The build order put token codegen and pure modules first, then the hardest algorithm (effective background) before any UI, so risk was retired early and every step ended in something runnable.
- Treat the agent's "not verified" notes as my to-do list. Everything that required the live Figma app became an explicit manual QA checklist.
- Maintain external memory. Long agent sessions get compacted and lose detail. The defence is state kept in files: a plan document, a
CLAUDE.mdpointing at it, git commits after each step, and/clearbetween chunks of work.
Three course corrections shaped the final product:
- "Carbon for the UI, not for evaluation" turned a fixed reference into a provider abstraction.
- "Both in parallel" was pushed back on. The agent recommended, and I accepted, building the shared rule engine first and treating the plugin and the web checker as two thin shells over it. That decision is why a v2 web surface is cheap.
- "Free only" reshaped the backend from a paid-AI-first design into a deterministic-first pipeline where AI is an optional enhancer. Cost constraints produced a better architecture, because they forced the cheap, exact methods to carry the load.
16. Known limitations and what's next
Being precise about limits is part of shipping a measuring tool:
- Approximate background resolution. Blend modes, layer effects, and partial overlaps aren't modelled; gradients and images are flagged rather than measured.
- ΔE76, not CIEDE2000. Adequate for near-duplicate detection, less uniform in saturated regions.
- Heuristic component rules. Figma has no reliable "this was meant to be a component" signal, so detached-instance and naming checks rely on name patterns.
- Deterministic rules only. The plugin measures; it does not judge. Hierarchy, clarity, and flow quality need a heuristic-evaluation layer (Nielsen-style, vision-model-assisted), which is the natural next stage and should remain advisory and review-gated like everything else.
- No history. There is no score time-series or regression tracking yet.
- Free-tier operational limits. Cold starts and an ephemeral in-memory cache.
- Not unique in the category but Different. Design Lint, YADL, FigmaLint, and others are mature and good. They largely check whether layers are bound to tokens that already exist in the Figma file. UiUX Report's differentiation is the reference-from-elsewhere path (curated systems, a pasted URL, or learned from screens), the no-system consistency mode, comparative 1 to 10 scoring, and rules editable by non-engineers.
Next on the list: tightening manifest network permissions to the minimum set before any Community submission, a side-by-side A/B scoring view, flow-level (multi-screen) evaluation, and score history per file.
Closing thought
The most useful thing I learned is that a spec-driven workflow rewards exactly the skills design systems people already have: defining a canonical model, writing unambiguous rules, thinking about failure states, and insisting on a review gate. The syntax turned out to be the least interesting part of the system. The architecture, the reconciliation policy, and the question "what must a human approve before this becomes truth?" were the real work.
If you're building developer or design tooling and have war stories about Figma's plugin sandbox, SSRF hardening, or perceptual colour metrics, I'd genuinely like to hear them in the comments.