UX Expert
ux-expert/
UX research and design critique for your agent: research plans, personas, storyboards, heuristic reviews and measured design forensics, with at most three priority actions.

UX research and design critique, the reasoning BEFORE a wireframe and the judgment AFTER a build.
Why use it
- Design decisions defended by taste instead of evidence.
- "Analyze this design" answered with guessed hex codes and invented font sizes.
- A reviewer who recites all ten heuristics and prioritizes nothing.
What it does
research, pick the method that fits the question × product stage, and mine the free evidence first.personas, 2-4 relevance-tested personas, each carrying its provenance (grounded, or clearly labelled proto).storyboard, one persona · one scenario · one path, with an emotion per panel.heuristics, Nielsen H1-H10 applied selectively: critique, or design guidance handed to the designer.review, the combined lens (usability · cognitive load/conversion · craft · a11y · AI-product) with a verdict and max 3 priority actions.analysis, design forensics: measured hexes, type scale, spacing ladder, contrast pairs, composition metrics, tokens.
Benefits
- Numbers, not opinions. Every hex, size and ratio is measured, anything inferred is labelled.
- Findings you can act on today: 3-5 issues, each with its cost, and max 3 priority actions.
- Know who you're designing for before the first wireframe, and whether the build served them after.
- No turf wars. It critiques and routes; it never touches your design system or your code.
Install
Download this skill and copy the ux-expert folder to .claude/skills/ux-expert/ in your project, or to ~/.claude/skills/ux-expert/ to use it in every project. Claude Code finds it on its own; you can also call it with /ux-expert.
Part of the Web Dev Design Pipeline bundle. To get the whole set as a Claude Code plugin: /plugin marketplace add endorphin-ai/hasbrains-agent-kit, then /plugin install web-dev@hasbrains-agent-kit.
Source: endorphin-ai/hasbrains-agent-kit. License: MIT.
Files (29)
Files
-
- ai-product-ux.md
- cognitive-load-and-conversion.md
- design-forensics-scripts.md
- eight-dimensions-and-tokens.md
- example.md
- honest-rendering.md
- journey-empathy-and-prioritization.md
- nielsen-heuristics.md
- persona-craft.md
- research-method-matrix.md
- storyboard-craft.md
- verification-instruments.md
- visual-craft-rules.md
- FORMAT.md
- README.md
- SKILL.md
- VOICE.md
# Verification Instruments — how to measure a UI without lying to yourself
> **Source**: learned the hard way. Every failure catalogued below produced a CONFIDENT WRONG ANSWER
> during a single review session — six from the reviewer, three more from independent reviewers.
> **Load when**: writing or reviewing any browser probe, before trusting a number it produces, or
> when a measured result disagrees with what you can see on screen.
---
## The rule this file exists for
**A metric that cannot fail is not evidence.**
Before a probe's output is allowed to change a verdict, prove the probe can report the failure it is
looking for. The cheapest proof: run it against a case you KNOW is broken and confirm it says so. A
probe that has only ever returned "clean" has not been tested — it has been assumed.
The corollary is worse than it sounds. A broken instrument does not produce noise, it produces
CONFIDENCE: a clean sweep, a green table, a claim you then repeat to the owner. Every entry below was
reported as fact before it was caught.
---
## The catalogue
Each entry is a real failure. The shape matters more than the specific API.
### 1. A viewport test that cannot fail — mobile emulation
```js
// WRONG — under isMobile the visual viewport WIDENS to match over-wide content,
// so scrollWidth tracks innerWidth and this is always false.
document.documentElement.scrollWidth > window.innerWidth
```
```js
// RIGHT — pin the device width, isMobile OFF, compare against the literal number.
const ctx = await browser.newContext({ viewport: { width: 375, height: 812 } });
// …
document.documentElement.scrollWidth > 375
```
Reported "0 pages overflow" for three genuinely overflowing routes. **Ask of any threshold test: what
would make this fire?**
### 2. A colour parser that invents failures
Modern stylesheets compute to `color(srgb 0.968 0.960 0.984 / 0.88)`, `oklch()`, `lab()`. A regex of
`/\d+(\.\d+)?/g` reads those 0-1 floats as 0-255 channels — i.e. near-black — and fabricates contrast
failures against every light surface.
```js
// RIGHT — let the engine normalise, then read it back.
const probe = document.createElement("div");
probe.style.color = String(value);
const out = getComputedStyle(probe).color; // now rgb() or color(srgb …), parse both
```
Produced **120 fabricated failures**, and made a token look WORSE after it was correctly darkened.
### 3. A background walk that stops at glass
Walking ancestors for "the background" and stopping at the first non-transparent value picks up a
TRANSLUCENT layer and treats it as opaque. Text on a 15% wash of its own colour then measures against
the wash instead of the composite.
- Skip any layer with alpha < 1 while walking, **or** composite it properly:
`out = fg·α + bg·(1−α)`.
- A badge sitting on a wash of its own text colour is the classic case; compute the composite or the
ratio is meaningless.
### 4. Inferring state from markup instead of measuring it
A closed `<details>` does **not** hide children that carry an author-origin `display` — the UA rule
loses to any author rule. Same trap for `hidden`, `aria-expanded`, and CSS classes that "mean" hidden.
```js
// WRONG
const hidden = !details.hasAttribute("open");
// RIGHT
const hidden = child.getBoundingClientRect().height === 0;
```
Hit **twice** in one session: once believing a fold worked when every body was rendering, once
believing a fix had failed when it had not.
### 5. Counting the wrong selector
A view's items are whatever the view calls them: a kanban's cards may be `.kb-card` while `.card` is
something else; a timeline's rows may be `.tl-row` while `.row` belongs to a list. Counting the
generic name reports a working view as EMPTY.
Before reporting "0 items", print the class names actually present:
```js
[...new Set([...container.children].map((e) => e.className.split(" ")[0]))]
```
### 6. A readiness check satisfied by the thing it should exclude
```js
// WRONG — an EMPTY container satisfies this, and so does the PREVIOUS route's content.
await page.waitForFunction(() => !/Loading/.test(document.querySelector("#main").textContent));
```
Worse, `.catch(() => {})` on the wait swallows the timeout, so a slow load silently proceeds and the
probe measures the loading stub — then blames the route. Wait for POSITIVE evidence of the thing you
want, with a real budget and no swallowed timeout:
```js
await page.waitForFunction(() => {
const m = document.querySelector("#main");
const t = (m?.textContent || "").trim();
if (!t || /^Loading/.test(t)) return false;
return m.querySelector("h1") !== null; // positive proof the view rendered
}, { timeout: 30000 }); // and let it THROW
```
This one produced a false bug report — an "intermittent async race in the view" that was the harness
all along. **A flaky test is a claim about the code; verify it in isolation before making it.**
### 7. The self-referential trap
A page that QUOTES your sentinel defeats a substring check. A review document containing the words
"Loading docs…" reads as still-loading forever. Prefer structural signals (an `h1` exists, a known
container has children) over string matching on user content.
### 8. Fighting the app for shared state
Setting `localStorage` in an init script races the app's own write, so a "light theme" pass can
silently run in dark. **Assert the state took effect before measuring anything that depends on it:**
```js
const theme = await page.evaluate(() => document.documentElement.getAttribute("data-theme"));
if (theme !== expected) throw new Error(`theme is ${theme}, not ${expected}`);
```
### 9. Scoring things that are not text
Emoji glyphs, icon fonts and decorative marks have no meaningful foreground colour. Including them in
a contrast sweep produces 1:1 ratios and drowns the real findings. Exclude them, and exclude
`aria-hidden="true"` nodes — WCAG exempts decoration.
---
## Verify the FIX, not the edit
An edit that compiles is not a fix. Two failure shapes, both seen in one session:
1. **The no-op fix.** Data was wrapped in a helper that attached a `hue` to every row — while the
consumer never read `.hue` and coloured by position. The code changed; the output did not.
2. **The plausible-but-wrong parameter.** A "start headings at level 2" option was passed where the
renderer already normalised h1→h2, so it pushed the document's own `##` down to `###` and produced
**zero** sections instead of the intended split.
Both were caught only by measuring the OUTPUT again after the change. So:
- Re-run the exact measurement that produced the finding, and require the NUMBER to move.
- If the number does not move, the cause is elsewhere — **stop patching and re-diagnose.** Chasing a
symptom produced two wrong fixes for one page before the real cause (a view switch that rendered
its own chart *and* the other view's full content) turned up.
---
## Fix the token, not the forty selectors
When many rules share one failing value, fixing the two you happened to measure leaves the rest — and
the next one to appear. A contrast pass that changed 2 selectors left 45 more failing; retuning the
TOKEN fixed all 68 at once and could be verified with exact arithmetic.
When you retune a shared token:
- compute against **every** surface it lands on, including composited/translucent chrome;
- keep hue and saturation, move lightness only, so the palette's identity survives;
- check it still reads as a distinct STEP from its neighbours in the ramp;
- verify with a live sweep AND with hex arithmetic — two instruments that fail differently.
---
## Enumerate the full state space, not the route list
A sweep that visits paths only will miss the states a router reaches through query parameters. Real
example: `?view=timeline`, `?view=board`, `?g=<group>`, `?item=<id>`, `?kind=<x>` — three of a nine-item
complaint list landed on sub-views that no earlier pass had ever driven, because the sweep enumerated
routes and stopped.
Before claiming coverage, read the router and enumerate:
- every path branch,
- every query parameter that changes what renders,
- every document/collection TYPE (not one representative — types diverge),
- the not-found branch, and the empty/filtered-to-empty state of each list.
Then say what you covered and what you sampled. "All pages" is a claim; make it a true one.
---
## Reporting rules that follow from all of this
1. Every number cites the instrument that produced it, or is marked `(inferred)`.
2. When an instrument is corrected, RE-STATE the affected numbers — do not leave the old ones standing.
3. When a claim you already reported turns out to be wrong, correct it plainly and say what the real
cause was. A quietly-dropped claim is worse than the original error.
4. Prefer two instruments that fail differently (a live sweep and exact arithmetic) over one you trust.