Legible

Legible

Legible

Product design and AI engineering · Web app

A tool that audits your Figma file and tells you what AI agents can’t read in it.

A tool that audits your Figma file and tells you what AI agents can’t read in it.

A tool that audits your Figma file and tells you what AI agents can’t read in it.

Role

Product design, architecture, and AI direction

Stack

Next.js 16 (App Router, RSC), Supabase, Clerk, Upstash Redis, Anthropic Sonnet 4.5, Vercel

Built

First commit April 19, 2026. Working audit deployed ten days later. Still shipping.

Code

github.com/knightjek23/MX-APP

Paste a Figma file URL and get a shareable audit in about thirty seconds.

The problem

Agent browsers are here. ChatGPT Atlas, Perplexity Comet, and Google’s Mariner all browse sites on someone’s behalf, and they read the page the way a screen reader does: through the DOM, the accessibility tree, and sometimes just pixels. If your pricing lives inside a div with an onclick handler, an agent shopping for your customer doesn’t see a price. It sees a box.

Designers have no way to catch this before it ships. The a11y annotation plugins that exist are framed as WCAG compliance, and compliance is the checkbox everyone defers. Nobody was auditing the Figma file, the place where the structure gets decided in the first place. Every agent-readiness tool I found scans a live site, which means you find out after you built the thing.

I’ve been designing dashboards and internal tools for years, so this is my own file I’m worried about. I ran an early version of the audit against my own SoloDesk dashboard frame and it caught six things I’d missed, including gradient-filled text that exports from Figma as clipped transparent nothing. That was the moment it stopped being a thought experiment.

What I decided before writing a prompt

I wrote the project doc before the first line of code. It is a thousand lines long, it is still in the repo root, and it is still the source of truth. The constraints that mattered:

  • The model returns structured output through tool use, not JSON in a text response. No parsing, no fence stripping, no retry loop on malformed JSON. My Zod schema is the tool’s input schema and tool_choice is forced to it.

  • Every external API in the audit pipeline goes through a typed service class. No fetch() in the audit route. FigmaService, ClaudeService, AuditService, and one pure compactTree function.

  • The Figma token is never persisted. Not in the database, not in logs, not sent to Claude. Only the compacted tree goes to the model.

  • Observability before features. Every successful audit writes tokens in, tokens out, latency, cost, and compacted tree size to Postgres. I wanted to know what an audit costs before I ever thought about pricing it.

  • What I was not building: no Figma plugin, no code export, no teams, no billing. The web app had to prove the audit was worth running before any of that earned a line of code.

Architecture

One route does the real work. POST /api/audit requires a signed-in user, rate limits per Clerk user ID, parses the Figma URL, pulls the file, counts frames, compacts the tree, calls Claude, persists the result, and returns a slug. Every failure mode along that path has its own error class and its own user-readable message.

Piece

Why

Next.js 16, App Router + RSC

Report pages are server components, so the audit renders from the database with no client fetch and no loading flash.

Supabase Postgres

One table, three version-controlled migrations, real SQL. The app talks to it through the service-role key only.

Clerk

Auth I do not want to own. Sessions, password reset, and Google OAuth for zero lines of my code.

Upstash Redis

Vercel functions do not share memory across instances, so an in-memory rate limiter is decoration.

Sonnet 4.5, tool use

The audit output has a strict shape, and tool use is how you get a strict shape without parsing.

Structured tool use instead of JSON parsing. The obvious build is: ask for JSON, strip the code fences, JSON.parse, retry when it fails. I registered AuditResultSchema as the input schema of a submit_audit tool and forced tool_choice to it instead. There is no JSON string in the pipeline, so there is no class of bug where the model wrapped its answer in prose. When the tool block is somehow missing, that is a typed ClaudeNoToolUseError, not a parse failure I have to guess at.

A log curve for the score, verified on my side. The first scoring formula was linear: subtract 7 per critical issue. Running it against the SoloDesk frame killed it. Six things an agent genuinely cannot parse, and the frame scored a 72, which reads like a passing grade. I changed it to 100 minus ceil(log2(p1 + 1) times 12) minus (p2 times 3) minus p3. That same frame now scores 51, and 40 issues still reads as meaningfully worse than 6. Then the server recomputes the score from the counts and overrides the model when they diverge by more than two points. The model is good at finding issues. It is not the thing I trust to do arithmetic.

1 · POST /api/audit

Signed-in user, rate-limited per Clerk ID, Figma URL parsed.

2 · FigmaService

Pulls the file and counts frames. The token is never stored.

3 · compactTree()

A pure function strips the tree, keeping every name, type, and text.

4 · ClaudeService

Sonnet 4.5 with forced tool use returns a typed, schema-shaped result.

5 · AuditService

Persists tokens, cost, and latency, recomputes the score, returns a slug.

How I directed the AI

Decomposition. Section 9 of the project doc is seven numbered prompts, in order, most of them naming the sections of the doc to read first. Prompt 1 is scaffold only and explicitly says do not implement any service logic yet. Prompt 2 is types, the system prompt, scoring, and URL validation, with the test cases spelled out. Prompt 3 is compaction and the three service classes. Prompt 4 is the API route. Prompt 5 is UI. The ordering is deliberate: schemas before services, services before routes, routes before anything visual, so each layer had something real to build against instead of a mock.

Here is Prompt 3, trimmed:

PROMPT 3 (trimmed)

Read PROJECT.md sections 3, 3.1, 3.2, and 6. Pay close attention to

Figma token handling.


Implement the compaction function and three service classes:

1. lib/compact.ts, pure function compactTree(raw). Follow the

keep/strip rules in section 3.1…

2. lib/services/figma.ts, FigmaService class

- Throw typed errors: InvalidTokenError, FileNotFoundError, FigmaApiError

- NEVER log the token

Run vitest. Commit: “service layer: compact, figma, claude (tool-use), audit”

Boundaries. Section 8 of the doc is the file tree, written before any file existed. Naming the boundaries up front is what stopped the model from inventing its own: compaction stays a pure function with no network access, services never touch each other, and route handlers own error-to-HTTP translation and nothing else.

Context. CLAUDE.md in the repo is one line long. It points at AGENTS.md, which says one thing: this version of Next.js has breaking changes, read the docs in node_modules before writing code, because your training data is wrong about this. The heavy context lives in the project doc, and prompts pull in the two or three sections they need instead of the whole thing. For the accounts work I wrote a design spec first with the non-goals listed explicitly, then a task-by-task implementation plan with a file map. When a build starts drifting, a checklist with file paths on it is what you use to pull it back.

The report leads with an AX score out of 100 and splits issues into critical, important, and suggested.

What I caught and changed

Directing a model this way means most of the work is review. A sample of what it produced, why it was wrong, and what shipped instead:

What it produced

Why it was wrong

What I did

Audit rationales with invented percentages, like “reduces task success by 40%”

Nobody measured that. A tool whose whole pitch is credibility cannot make up numbers, and a designer who checks one citation and finds nothing is gone for good.

Rewrote the prompt so rationales explain the mechanism or cite the one study I can actually point to. The example rationales now model that shape.

The same problem in my own doc: a “27% AI conversion rate” I had been quoting

I went looking for the primary source and there was not one. The accessibility-tree framing of the study was also wrong; the drop came from a restricted viewport.

Fixed the system prompt first, since that is the copy users actually read. A small lesson about where a correction has to land to count.

max_tokens set to 8192

A full-file audit with dual-view copy on every annotation overflows that and cuts the tool input off mid-JSON. It surfaced as a schema validation error, so the message pointed at the wrong thing.

Raised to 16384 and added an explicit stop_reason check that throws a truncation error telling the user to scope to a single frame.

Row Level Security left off the audits table

The app only talks to Supabase through the service-role key, so nothing looked broken. But the anon key could hit the auto-generated REST API and read or write the table directly.

Migration 003 enables RLS with no policies at all. Service role bypasses it; everything else gets nothing.

What I kept: the compaction module. The keep/strip rules were mine, but the implementation came back clean on the first pass, and the tests I asked for proved the thing I cared about, that stripping the tree down that far still preserved every name, type, and piece of text content. I have changed almost nothing in that file since.

Design

The report is the product, so the interface question was which reader it is for. Designers do not want to read aria-label, and developers do not want to read “treat this sidebar as a Navigation component.” So every annotation carries both, and one toggle switches the whole report between Design and Engineer views. The preference persists, because nobody wants to set that twice.

The wait was the other real design problem. An audit takes around 29 seconds, far too long for a spinner. Instead the form is replaced by the actual pipeline stages, revealed one at a time as they complete: reading your file from Figma, compacting the design tree, Claude scanning for issues, saving the report. The active line shimmers. It is honest about what is happening, and the difference between “please wait” and “here is what I am doing right now” is most of the perceived speed.

Design view speaks in components and structure.

Engineer view carries the aria-label and the suggested markup.

The 29-second wait shows the actual pipeline stages, revealed as each one completes.

Where it landed

It is deployed and it works end to end. A handful of designers have run real audits. The validation run against my SoloDesk frame came in around $0.03 and 29 seconds, which is what made per-audit credit pricing look reasonable later.

There is a hard cap of 5 audits per user, checked before any expensive work runs, so a beta cannot quietly torch my API budget. Auth gates creating an audit, but the slug stays a public share link, so you can send a report to a teammate and they can read it without an account. A daily Vercel cron pings /api/health, which keeps the free-tier Supabase project from pausing itself.

No revenue yet. Stripe and the credit packs are still ahead of me.

What I would do differently

I would have shipped Figma OAuth. The plan called it Week 1.5 and it still has not landed, so the form still asks for a personal access token. That is the highest-friction field on the page by a wide margin, and I am asking a stranger to paste a credential into a tool they have never heard of. Every cold visitor who bounces there is a data point I will never see.

I would bound the audit output earlier. The truncation bug was not hard to fix once I understood it, but I lost real time to an error message that pointed at schema validation when the actual problem was a token ceiling. Anywhere a model output feeds a parser, the “it ran out of room” case deserves its own error before you write the happy path.

I would separate the visual passes from the product passes. There is a stretch of the history that is four landing page commits in one evening, plus two reverts pulling most of it back out two days later. That is me designing in the repo instead of designing first and then implementing. It is the cheapest thing on this list to fix and I still do it.