Audit a React/shadcn app for missing UI fundamentals — accessibility, interaction, empty/error/loading states, form wiring, responsive shell, production polish — with the shadscan CLI, then route each real fix to the dev-flow skill that owns it. One of five pre-deploy gates, with `compliance-audit` (legal), `vercel-doctor` (cost), `launch-audit` (launch readiness) and `app-security` (code security); dev-flow proposes it at `feature_complete` and in the `deployed` maintenance loop. Use when th...
Installs into .claude/skills of the current project.
Are you the author of Shadscan?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/lukedj78-shadscan)
---
name: shadscan
description: 'Audit a React/shadcn app for missing UI fundamentals — accessibility, interaction, empty/error/loading states, form wiring, responsive shell, production polish — with the shadscan CLI, then route each real fix to the dev-flow skill that owns it. One of five pre-deploy gates, with `compliance-audit` (legal), `vercel-doctor` (cost), `launch-audit` (launch readiness) and `app-security` (code security); dev-flow proposes it at `feature_complete` and in the `deployed` maintenance loop. Use when the user says "shadscan", "audit my UI", "check accessibility", "is this app accessible", "a11y audit", "missing loading/empty states", "UI quality check", or dev-flow routes here before a web deploy. Refuses for non-React / non-shadcn targets (mobile RN is out of scope). Not for: legal/privacy audit (use compliance-audit), Vercel cost (use vercel-doctor), launch readiness (use launch-audit), designing the UI (use design-md-to-app), or writing tests (use write-tests).'
---
# shadscan — UI-quality & accessibility pre-deploy gate for React/shadcn apps
Runs on a web project that **already exists** and uses **shadcn**. It wraps the third-party
[shadscan](https://www.shadscan.com/) CLI — a deterministic static audit of *UI fundamentals* — and turns
its report into applied fixes, routing each finding to the skill that owns it.
This is the gate that covers what neither sibling can see. `compliance-audit` reads the legal surface,
`vercel-doctor` reads the cost surface; **nothing in dev-flow mechanically verified that the UI we
prescribe actually got built** — that the reduced-motion guard `transitions` mandates is really there,
that the form errors `forms` specifies are really rendered, that a route has a loading boundary at all.
> **Third-party tool.** shadscan is **not** an official shadcn product — it's an independent open-source
> project by [TheOrcDev](https://github.com/TheOrcDev/shadscan), published to npm as **`@shadscan/cli`**
> (bin `shadscan`, **MIT**). Verified against the npm registry + repo README (at time of writing:
> **0.9.0**, ruleset `2026.07.41`, report `schemaVersion 9` — the report shape is versioned, re-check it).
> From the README, verbatim: *"The default scan is deterministic and read-only. It does not start the app,
> edit files, call an AI model, upload source, or require application secrets."* That makes it safe to run
> on a private codebase without the `--offline` dance `vercel-doctor` needs.
## Verified invocation + flags
```bash
npx --yes @shadscan/cli@0.9.0 # explore: human output, same pin
npx --yes @shadscan/cli@0.9.0 --json --no-interactive \
> docs/ui/shadscan.json # audit: PINNED — the mode this skill uses
npx --yes @shadscan/cli@0.9.0 --fail-under 70 --no-interactive # CI gate: pinned + ratcheted
```
⚠️ **Pin the version for anything you will compare.** `npx --yes @shadscan/cli` resolves to latest *on
every call* — during a single session it went 0.9.0 → 0.10.0 (ruleset `2026.07.41` → `2026.07.42`, 59
rules → 60), silently making a before/after diff a comparison across two different rulesets. shadscan
knows this: the `verification.shadscanCommand` it emits in its own acceptance criteria is **already
pinned** (`pnpm dlx @shadscan/cli@0.9.0 --json`). Follow it. Pin the baseline run, pin the re-run, and
only drop the pin when you deliberately want the newer ruleset — then re-baseline instead of diffing.
The explore line is pinned too: `--yes` runs a third-party package without asking, and an unpinned one
runs whatever was published last.
`pnpm dlx @shadscan/cli` and `bunx @shadscan/cli` work identically. Supported frameworks (per the README):
**Next.js, Vite, TanStack Start, Laravel, Astro, React Router** — it auto-detects the adapter and reports
it (`framework.adapter`, e.g. `next-app-router`) plus how confident it is that the project is shadcn at all.
| Flag | Effect |
|---|---|
| `[path]` | project directory to scan (default `.`) |
| `--json` | machine-readable report — **the mode this skill uses** |
| `--format <human\|json\|prompt>` | output shape |
| `--prompt` | print only a paste-ready remediation prompt for an agent |
| `--fail-under <score>` | exit non-zero below the score, **or when unassessed / partial source coverage** |
| `--category <category>` | run one audit category only |
| `--list-projects` / `--project <path>` | inspect / pick one workspace package instead of pooling them all |
| `--no-roast` / `--roast` | neutral copy vs. the snarky one-liners (roast is on by default in human output) |
| `--no-interactive` | disable follow-up prompts (**use in CI and in any agent run**) |
| `--apply` + `--agent <claude\|codex\|grok>` | hand the remediation prompt to an installed coding-agent CLI |
| `-V, --version` / `-h, --help` | version / help |
| `setup [path]` | write an explicit project integration |
| `mcp [paths...]` | serve shadscan as an **MCP server over stdio** for coding agents |
If a flag above is rejected, the CLI has moved — run `npx --yes @shadscan/cli@0.9.0 --help` (or the new
pin, if you are moving to it on purpose) and use what it reports rather than guessing.
⚠️ **Do not use `--apply`.** It shells out to another agent CLI to do the work. You *are* the agent —
read the JSON, triage it against the rules below, and apply the fixes yourself with the owning skill's
knowledge. `--apply` throws away the routing that makes this skill worth anything.
## The six categories (weights are the score, out of 100)
| Category | Weight | What it checks | Who fixes it in dev-flow |
|---|---:|---|---|
| **Foundation** | 20 | `components.json` parses, theme provider mounted in the shell, theme hydration safe, toast provider mounted | **`design-md-to-app`** (shell + providers), **`coss-ui`** when `stack.ui = "coss"` |
| **Interaction** | 20 | command menu present + `Cmd/Ctrl+K` bound, mobile nav trigger + controlled panel, responsive shell, keyboard navigation | **`design-md-to-app`** (it owns the shell/layout from DESIGN.md) |
| **States** | 20 | empty state, error state with retry, `not-found` recovery, route loading boundary, useful Suspense fallback | **`data-fetching`** (boundaries, Suspense, `loading.tsx`) + `design-md-to-app` (the *designed* state) |
| **Accessibility** | 20 | alt text, colour contrast, focus-visible not suppressed, dialog focus trap, heading structure, no positive `tabindex`, labels | **this skill** (mechanical) — icon a11y → **`animated-icons`**, motion → **`transitions`** |
| **Forms and Data Entry** | 10 | validation wired to the form, field errors rendered, invalid fields associated with their errors, async action pending state | **`forms`** |
| **Production Polish** | 10 | metadata title + description, social preview image, public SEO files, no starter copy left behind | **`design-md-to-app`** (metadata) + the deploy step |
Two rules deserve a name-check because they enforce **our own contract**:
`animations-respect-reduced-motion` is verbatim the core rule of the `transitions` skill, and
`forms-have-labels` / `invalid-fields-associated-with-errors` are what `forms` prescribes. shadscan is
the first thing in dev-flow that can *prove* those landed.
## Run, then route
1. **Preconditions.** `meta.json#stack.framework ∈ {"next","monorepo"}` and the project uses shadcn
(`components.json` present). Refuse for `expo-rn` — the rules are DOM/React-web only. In a monorepo,
`--list-projects` first, then `--project <path>` per app; pooling every package produces one
meaningless blended score.
2. **Run** `npx --yes @shadscan/cli@<pinned> --json --no-interactive > docs/ui/shadscan.json`. Read
`score`, `grade`, `categories[]`, and — critically — **`coverage.source`**. If coverage is not
`"complete"`, the scan saw only part of the source and the score is not comparable to a previous run;
say so instead of reporting a number.
2b. **Count hand-made UI beside the score — golden rule 3, `design/no-handmade-ui`.** shadscan audits
what the UI is missing; it does not see a raw `<button>` that looks right and works, and neither did
anything else on smart-travel, where 238 hand-made elements sat behind a green lint (2026-10-12). Run
the project's rule on the app and report its count in the gate summary and in
`meta.json#shadscan.handmade_ui` (with `card_in_card` beside it):
```bash
pnpm exec eslint . -f json 2>/dev/null | node -e 'let r=JSON.parse(require("fs").readFileSync(0,"utf8")),c={};for(const f of r)for(const m of f.messages)if(/^design\//.test(m.ruleId||""))c[m.ruleId]=(c[m.ruleId]||0)+1;console.log(JSON.stringify(c))'
```
A non-zero `design/no-handmade-ui` is a gate finding of its own, whatever the score: route each hit
as golden rule 3 below (replace it with the primitive, never patch the copy, never `eslint-disable` it;
a missing variant goes to the user as a DESIGN.md decision). No `design/no-handmade-ui` in the
project at all is a finding too — the preset predates the rule and the write-time guard is missing:
the retrofit is `design-md-to-app/scripts/setup_design_lint.py <root>`.
3. **Triage from `agentHandoff.actionables`, not from the human output.** Every actionable carries a
`disposition`, a `confidence`, and the `file:line` it was found at. Act on the pair, never on the score:
| `disposition` | What you do |
|---|---|
| `fix` | **Open the file and the code around it. Every time, whatever the confidence.** Fix only what survives that reading — on a real run 7 of 9 did not. See §Trust calibration for which rule kinds to disbelieve. |
| `decide` | **Do not invent an answer** — it's a product question ("should this app have a command menu?"). Surface it to the user with the trade-off and move on. |
| `verify` | Read the code and confirm. Many are advisories that **did not reduce the score**; a `pass` you can't reproduce is a finding about the detector, not the app. |
⚠️ **`confidence` is not a precision estimate — don't gate the work on it.** Measured on a real run,
`high` items split 2 true / 3 false. What predicts correctness is the *kind of question the rule asks*
(does a file exist? vs. is this component semantically complete?), not the label shadscan attaches.
Each actionable ships `acceptanceCriteria` — machine-checkable, including a re-run of the rule and the
project's own gates (`verification.projectGates`, e.g. `pnpm lint`, `pnpm typecheck`, `pnpm build`).
**Run those gates after fixing** and report any you were not authorised to run.
🚫 **Never optimise for the score.** shadscan says this itself, and it is the rule that keeps this gate
honest: *do not add unused infrastructure solely to increase the audit score*, and *do not edit solely
to force score-neutral static advisories to report pass*. A command menu nobody asked for is a
regression that scores well. If a fix has no user-facing benefit, it is a `decide`, not a `fix`.
4. **Apply the fixes that survived step 3** here when they belong to nobody else — a missing file
(`not-found.tsx`, `opengraph-image.tsx`, `robots`/`sitemap`), a genuinely missing `alt`, a positive
`tabindex`, a heading level skipped.
⚠️ Two of those look mechanical and are not: a **suppressed focus ring** is only a defect if no
ancestor carries `focus-within:`/`has-[…]:focus` — check the wrapper first; an **unlabelled control**
inside `components/ui/` is usually a design-system primitive, where the label is the consumer's job
and adding one inside is the wrong fix.
When a fix creates a new surface, finish it: an `opengraph-image` without `metadataBase` resolves to
`localhost` and shows nothing in production, and `ImageResponse` (Satori) **does not support `ch`
units** — a `maxWidth: "20ch"` silently collapses and renders one word per line, through a green
build. **Look at the generated image**, don't trust the exit code.
5. **Route the rest** to the owning skill rather than hand-fixing (table above). Don't re-implement
`forms`' error-association pattern or `data-fetching`'s boundary ladder inside this skill.
6. **Persist**: keep `docs/ui/shadscan.json` (+ the human report if useful), update `meta.json#shadscan`,
append `history`. **No phase bump.**
### ⚠️ Trust calibration — what a real run actually showed
Run against a live Next 16 + eve project: **64/100, grade D**, 59 rules (28 pass, 12 fail, 11
not-applicable, 8 advisory), 17 actionables — 9 `fix`, 7 `verify`, 4 `decide`. Every `fix` was opened in
the source. **Two of the nine survived.**
The seven that did not, and why — this table is the skill's real content:
| Finding | Why it was wrong |
|---|---|
| `focus-visible-not-suppressed` | the inner input's `outline-none` is deliberate; the **wrapper** carries `focus-within:ring-3` |
| `async-action-pending-state` | the parent passes `disabled={busy}` and renders a `Spinner` — pending state is **lifted**, not absent |
| `suspense-fallback-useful` | the `fallback={null}` is inside a **WebGL `<Canvas>`**, where DOM nodes are illegal; `null` is correct |
| `links-have-accessible-names` | the link is a **Base UI `render` prop**; the accessible name arrives as the parent's children |
| `empty-state-present` | the "data-backed collection" is a **static table authored by hand** |
| `validation-wired-to-form` | a chat composer needs no form library |
| `forms-have-labels` | flagged a **design-system primitive**; a primitive carries no label, its consumer does |
**The pattern: shadscan is precise about what one file contains, and blind to composition.** It never
resolves speculatively — every string it quoted was really there — but it cannot follow a prop across a
component boundary, climb to a wrapper, see through a `render` prop, or know that a subtree renders to
WebGL instead of the DOM. **The better-composed the codebase, the more false positives it produces.**
So trust it by *what kind of question the rule asks*:
| Rule kind | Examples | Trust |
|---|---|---|
| **Does this file exist?** | `not-found-route-present`, `social-preview-present`, `public-app-seo-files-present`, `shadcn-config-present` | **high** — no code comprehension needed; both true positives were of this kind |
| **What is in this one file, self-contained?** | `no-positive-tabindex`, `images-have-alt` on a literal `<img>` | medium — usually right |
| **Is this component semantically complete?** | pending state, empty state, labels, focus rings, Suspense fallbacks | **low — assume wrong until the surrounding composition proves otherwise** |
Read the **parent, the wrapper and the render target** before touching anything in the third row. Cost of
being wrong is asymmetric: a missed finding is a small gap, a "fix" applied to a false positive damages
working code and its author's trust in the gate.
This is a *different* failure mode from `vercel-doctor`'s, not a milder one. There, `knip` invented
import graphs and flagged 58% of a codebase as dead. Here nothing is invented — the tool simply cannot
see past the file boundary, and good composition is what puts the answer on the other side of it.
### When the fix is real but the rule stays red
`mobile-nav-present` failed on a header that hid its whole `<nav>` below `sm` with no replacement — a
real defect: on a phone one route was unreachable. The fix was to keep two links visible and let the
wordmark step aside, verified at 375 px. **The rule still failed**: the detector looks for a trigger +
controlled panel pair, and does not recognise the "keep links visible in an explicit small-screen layout"
alternative that its own remediation text offers.
The correct outcome is the one that feels wrong: **ship the fix, leave the rule red, write down why.**
Adding a hamburger menu for two links to turn the rule green is the textbook case of the anti-gaming rule
above. When a remediation's stated alternative is not what the detector checks, the detector is the
narrower of the two — follow the user's interest, not the score.
## `meta.json#shadscan` block
```jsonc
"shadscan": {
"last_run_at": "<ISO>",
"engine_version": "0.9.0", // report.engineVersion
"ruleset_version": "2026.07.41", // report.rulesetVersion — the score only compares within a ruleset
"coverage": "complete", // report.coverage.source — anything else invalidates the score
"score": 0, // 0-100
"grade": "D",
"categories": { "foundation": 0, "interaction": 0, "states": 0,
"accessibility": 0, "forms": 0, "production-polish": 0 },
"fixed": ["not-found-route-present","social-preview-present"],
"decisions_open": ["command-menu-present"], // disposition=decide, awaiting the user
// Opened, read, and rejected — with the reason, so the next run doesn't re-litigate them.
"false_positives": {
"focus-visible-not-suppressed": "wrapper carries focus-within:ring",
"suspense-fallback-useful": "inside a WebGL <Canvas>; null is correct"
},
// Real fix shipped, rule still red because the detector checks something narrower.
"rules_left_red": {
"mobile-nav-present": "links kept visible at <sm; detector wants a trigger+panel pair"
},
// golden rule 3, counted beside the score (step 2b): design/no-handmade-ui, design/no-card-in-card.
// null = the rule is not in the project (itself a finding: retrofit with setup_design_lint.py)
"handmade_ui": 0,
"card_in_card": 0,
"routed": { "forms": ["field-errors-rendered"],
"data-fetching": ["route-loading-boundary-present"],
"design-md-to-app": ["theme-hydration-safe"] }
}
```
⚠️ **Only compare scores within the same `rulesetVersion`.** A new ruleset adds rules; a score that
"dropped" across versions may be the same app measured against more checks. This is not theoretical — see
the pinning warning above; it fired on the very first real before/after. **Assert `rulesetVersion` equality
in code before reporting a delta**, rather than trusting that two runs minutes apart used the same tool.
## dev-flow hook
Horizontal capability — run any time. dev-flow **proposes it as a pre-deploy gate** at `feature_complete`,
one of five: `compliance-audit` (legal) · `vercel-doctor` (cost) · **`shadscan` (UI quality + a11y)** · `launch-audit` (commercial readiness) · `app-security` (code security).
Re-run it in the `deployed` maintenance loop to catch UI regressions. It records `meta.json#shadscan` +
`history` and **never bumps `phase`**. It never *blocks* the deploy on its own — it surfaces the score and
the open decisions so the user decides.
**In CI** the natural form is `npx --yes @shadscan/cli@<pinned> --json --no-interactive --fail-under <N>` — pinned, like every run you compare — with `N`
set to the score already achieved (a ratchet), not to an aspirational number. `--fail-under` also fails on
partial coverage, which is the behaviour you want.
## Ecosystem-first: shadscan ships its own agent skills
The repo carries `.agents/skills/` — read them from source rather than paraphrasing:
- **`migrate-radix-to-base/`** (SKILL.md + 9 reference files) — a Radix → **Base UI** migration guide.
Directly relevant: our contract makes Base UI the default `ui_base`, so this is the upstream-maintained
path for a project still on Radix. Prefer it over writing our own migration notes.
- **`shadcn/rules/`** — base-vs-radix, chat, composition, forms, icons, styling.
Consistent with rule zero: when the tool ships the knowledge, use the tool's copy.
## Relationship to other skills
- **`compliance-audit`** / **`vercel-doctor`** — the sibling gates. Same shape: `feature_complete`
pre-deploy, no phase bump, "auto-fix safe + route/flag the rest". Different surface: legal, cost, UI.
- **`transitions`** — owns `prefers-reduced-motion`. shadscan **detects** the violation, `transitions`
**defines** the fix (tokenised motion, cheapest tier first).
- **`forms`** — owns validation wiring, error rendering and error association. Route every `forms`
category finding there.
- **`data-fetching`** — owns Suspense boundaries, `loading.tsx` and the read ladder behind the *States*
category.
- **`design-md-to-app`** / **`coss-ui`** — own the shell, providers, responsive layout and metadata:
most of *Foundation*, *Interaction* and *Production Polish*.
- **`composition-patterns-guide`** — owns component anatomy (input groups, item grouping, alert anatomy).
- **Golden rule 3** (`references/contracts.md` §Golden rules) — a finding whose cause is **a component
that duplicates a primitive** (a hand-built dropdown missing ARIA, a custom modal without a focus trap, a
`div` skeleton without `aria-busy`, a home-made empty state) is not fixed by patching the copy: the fix
is to replace it with the installed primitive. Route by where it lives — a page's `_components/` →
`screenshot-to-page`; a form control → `forms`; a module's screen → `module-add`; the shell or a
primitive missing from `components/ui/` → `design-md-to-app` (`shadcn add <name>`; Coss/UI → `coss-ui`).
Adding the missing ARIA to the hand-rolled copy makes the rule go green and keeps the violation.
**The same routing applies to every `design/no-handmade-ui` hit** counted at step 2b (raw elements,
hand-written roles, classes that redraw a Card/Badge/Alert/Separator): the message names the primitive;
the fix is that primitive — «the primitive renders the design»: if it looks different from the board or
the screen it replaces, tune its `cva` variant and DESIGN.md tokens until a before/after pixel diff is
zero; never correct the board, never rebuild the look with classes at the call site.
Example, from an actionable to the route:
> `interaction/menu-keyboard` · `fix` · `app/(app)/_components/account-menu.tsx:9` — *menu items not
> reachable by keyboard.* Reading the file: a `div` list toggled by `useState`, no roving focus.
> `components/ui/dropdown-menu.tsx` is installed. → **Golden rule 3**, routed to `screenshot-to-page`:
> replace with `DropdownMenu`; do not add `tabIndex`/`onKeyDown` to the copy.
- **`write-tests`** — complementary, not overlapping: shadscan is static and finds *missing* fundamentals;
tests assert *behaviour*. A shadscan fix is a good prompt for a regression test.
## Definition of Done
- `docs/ui/shadscan.json` written; `meta.json#shadscan` populated with score, grade, per-category
breakdown, **ruleset version** and **coverage**.
- Every `fix` actionable **opened in the source**, then either applied or declined **with the reason
recorded** (which parent/wrapper/render target made it a false positive); every `decide` surfaced to
the user as an open question; every `verify` checked in code.
- Any fix that is real but leaves its rule red is **shipped anyway and documented** — in the commit
message and in `meta.json#shadscan.rules_left_red`, so the next run doesn't re-litigate it.
- The project's own gates (`verification.projectGates`) run green after the fixes — or the ones you
couldn't run named explicitly.
- Nothing was added purely to move the number.
## The gate next door
`launch-audit` asks the question this one does not: **can a stranger find this, trust it, and buy
from it?** The overlap is deliberate and narrow, and stated in both directions so neither skill
assumes the other did it:
| | this skill | `launch-audit` |
|---|---|---|
| `not-found` route | checks it **exists** | — |
| social preview | checks the **file exists** | checks it **resolves and has dimensions** |
| SEO files | checks they exist | checks `robots` does not blanket-disallow and the sitemap names the public routes |
| metadata per route | — | title and description on **every** public route |
| privacy / terms / identity block | — | owns it |
| a11y, empty states, labels, focus | owns it | defers here, always |
A product passes this gate and still has no company name on it. That is not a hole in this skill —
it is a different question, and it has its own gate now.
## What this skill does NOT do
- **Not a legal/privacy audit** — that's `compliance-audit`. **Not a cost audit** — that's `vercel-doctor`.
- **Doesn't design the UI** — it checks that the built UI has its fundamentals; `design-md-to-app` designs.
- **Doesn't cover mobile** — the rules are DOM/React-web; Expo/RN has no equivalent here. And the *web-on-a-phone* layer (sticky hover, the tap-highlight flash, `100dvh` vs `100svh`, `touch-action`, safe areas) is not in its 62 rules either — that is `design-md-to-app/references/mobile-web.md`.
- **Doesn't feed the UI bad data.** Every rule reads the built output against whatever data was there. A component that passes all 62 can still break on a hyphenated surname or a count of exactly 1 — that is `write-tests/references/worst-case-data.md`, and it is a different operation, not a stricter setting.
- **Doesn't use `--apply`** — the routing to owning skills is the whole point.
- **Doesn't bump `phase`**, and never blocks deploy by itself.
## Sources
- Official site: https://www.shadscan.com/
- Repo + rules (`packages/cli/src/rules/`, 62 rules) + bundled agent skills: https://github.com/TheOrcDev/shadscan
- npm: https://www.npmjs.com/package/@shadscan/cli
## Reference files
- `references/contracts.md` — the `.workflow/` dev-flow contract (vendored).