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 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, 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
npx --yes @shadscan/cli # explore: latest, human output
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.
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 --help 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
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.
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.
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.
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.
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.
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
"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"
},
"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,
completing the trio: compliance-audit (legal) · vercel-doctor (cost) · shadscan (UI quality + a11y).
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 --json --no-interactive --fail-under <N>, 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).
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.
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.
- Doesn't use
--apply — the routing to owning skills is the whole point.
- Doesn't bump
phase, and never blocks deploy by itself.
Sources
Reference files
references/contracts.md — the .workflow/ dev-flow contract (vendored).
1---2name: shadscan3description: 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. The third pre-deploy gate, alongside `compliance-audit` (legal) and `vercel-doctor` (cost); 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), designing the UI (use design-md-to-app), or writing tests (use write-tests).4---56# shadscan — UI-quality & accessibility pre-deploy gate for React/shadcn apps78Runs on a web project that **already exists** and uses **shadcn**. It wraps the third-party9[shadscan](https://www.shadscan.com/) CLI — a deterministic static audit of *UI fundamentals* — and turns10its report into applied fixes, routing each finding to the skill that owns it.1112This is the gate that covers what neither sibling can see. `compliance-audit` reads the legal surface,13`vercel-doctor` reads the cost surface; **nothing in dev-flow mechanically verified that the UI we14prescribe actually got built** — that the reduced-motion guard `transitions` mandates is really there,15that the form errors `forms` specifies are really rendered, that a route has a loading boundary at all.1617> **Third-party tool.** shadscan is **not** an official shadcn product — it's an independent open-source18> project by [TheOrcDev](https://github.com/TheOrcDev/shadscan), published to npm as **`@shadscan/cli`**19> (bin `shadscan`, **MIT**). Verified against the npm registry + repo README (at time of writing:20> **0.9.0**, ruleset `2026.07.41`, report `schemaVersion 9` — the report shape is versioned, re-check it).21> From the README, verbatim: *"The default scan is deterministic and read-only. It does not start the app,22> edit files, call an AI model, upload source, or require application secrets."* That makes it safe to run23> on a private codebase without the `--offline` dance `vercel-doctor` needs.2425## Verified invocation + flags2627```bash28npx --yes @shadscan/cli # explore: latest, human output29npx --yes @shadscan/cli@0.9.0 --json --no-interactive \30 > docs/ui/shadscan.json # audit: PINNED — the mode this skill uses31npx --yes @shadscan/cli@0.9.0 --fail-under 70 --no-interactive # CI gate: pinned + ratcheted32```3334⚠️ **Pin the version for anything you will compare.** `npx --yes @shadscan/cli` resolves to latest *on35every call* — during a single session it went 0.9.0 → 0.10.0 (ruleset `2026.07.41` → `2026.07.42`, 5936rules → 60), silently making a before/after diff a comparison across two different rulesets. shadscan37knows this: the `verification.shadscanCommand` it emits in its own acceptance criteria is **already38pinned** (`pnpm dlx @shadscan/cli@0.9.0 --json`). Follow it. Pin the baseline run, pin the re-run, and39only drop the pin when you deliberately want the newer ruleset — then re-baseline instead of diffing.4041`pnpm dlx @shadscan/cli` and `bunx @shadscan/cli` work identically. Supported frameworks (per the README):42**Next.js, Vite, TanStack Start, Laravel, Astro, React Router** — it auto-detects the adapter and reports43it (`framework.adapter`, e.g. `next-app-router`) plus how confident it is that the project is shadcn at all.4445| Flag | Effect |46|---|---|47| `[path]` | project directory to scan (default `.`) |48| `--json` | machine-readable report — **the mode this skill uses** |49| `--format <human\|json\|prompt>` | output shape |50| `--prompt` | print only a paste-ready remediation prompt for an agent |51| `--fail-under <score>` | exit non-zero below the score, **or when unassessed / partial source coverage** |52| `--category <category>` | run one audit category only |53| `--list-projects` / `--project <path>` | inspect / pick one workspace package instead of pooling them all |54| `--no-roast` / `--roast` | neutral copy vs. the snarky one-liners (roast is on by default in human output) |55| `--no-interactive` | disable follow-up prompts (**use in CI and in any agent run**) |56| `--apply` + `--agent <claude\|codex\|grok>` | hand the remediation prompt to an installed coding-agent CLI |57| `-V, --version` / `-h, --help` | version / help |58| `setup [path]` | write an explicit project integration |59| `mcp [paths...]` | serve shadscan as an **MCP server over stdio** for coding agents |6061If a flag above is rejected, the CLI has moved — run `npx --yes @shadscan/cli --help` and use what it62reports rather than guessing.6364⚠️ **Do not use `--apply`.** It shells out to another agent CLI to do the work. You *are* the agent —65read the JSON, triage it against the rules below, and apply the fixes yourself with the owning skill's66knowledge. `--apply` throws away the routing that makes this skill worth anything.6768## The six categories (weights are the score, out of 100)6970| Category | Weight | What it checks | Who fixes it in dev-flow |71|---|---:|---|---|72| **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"` |73| **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) |74| **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) |75| **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`** |76| **Forms and Data Entry** | 10 | validation wired to the form, field errors rendered, invalid fields associated with their errors, async action pending state | **`forms`** |77| **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 |7879Two rules deserve a name-check because they enforce **our own contract**:80`animations-respect-reduced-motion` is verbatim the core rule of the `transitions` skill, and81`forms-have-labels` / `invalid-fields-associated-with-errors` are what `forms` prescribes. shadscan is82the first thing in dev-flow that can *prove* those landed.8384## Run, then route85861. **Preconditions.** `meta.json#stack.framework ∈ {"next","monorepo"}` and the project uses shadcn87 (`components.json` present). Refuse for `expo-rn` — the rules are DOM/React-web only. In a monorepo,88 `--list-projects` first, then `--project <path>` per app; pooling every package produces one89 meaningless blended score.902. **Run** `npx --yes @shadscan/cli@<pinned> --json --no-interactive > docs/ui/shadscan.json`. Read91 `score`, `grade`, `categories[]`, and — critically — **`coverage.source`**. If coverage is not92 `"complete"`, the scan saw only part of the source and the score is not comparable to a previous run;93 say so instead of reporting a number.943. **Triage from `agentHandoff.actionables`, not from the human output.** Every actionable carries a95 `disposition`, a `confidence`, and the `file:line` it was found at. Act on the pair, never on the score:9697 | `disposition` | What you do |98 |---|---|99 | `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. |100 | `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. |101 | `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. |102103 ⚠️ **`confidence` is not a precision estimate — don't gate the work on it.** Measured on a real run,104 `high` items split 2 true / 3 false. What predicts correctness is the *kind of question the rule asks*105 (does a file exist? vs. is this component semantically complete?), not the label shadscan attaches.106107 Each actionable ships `acceptanceCriteria` — machine-checkable, including a re-run of the rule and the108 project's own gates (`verification.projectGates`, e.g. `pnpm lint`, `pnpm typecheck`, `pnpm build`).109 **Run those gates after fixing** and report any you were not authorised to run.110111 🚫 **Never optimise for the score.** shadscan says this itself, and it is the rule that keeps this gate112 honest: *do not add unused infrastructure solely to increase the audit score*, and *do not edit solely113 to force score-neutral static advisories to report pass*. A command menu nobody asked for is a114 regression that scores well. If a fix has no user-facing benefit, it is a `decide`, not a `fix`.1154. **Apply the fixes that survived step 3** here when they belong to nobody else — a missing file116 (`not-found.tsx`, `opengraph-image.tsx`, `robots`/`sitemap`), a genuinely missing `alt`, a positive117 `tabindex`, a heading level skipped.118119 ⚠️ Two of those look mechanical and are not: a **suppressed focus ring** is only a defect if no120 ancestor carries `focus-within:`/`has-[…]:focus` — check the wrapper first; an **unlabelled control**121 inside `components/ui/` is usually a design-system primitive, where the label is the consumer's job122 and adding one inside is the wrong fix.123124 When a fix creates a new surface, finish it: an `opengraph-image` without `metadataBase` resolves to125 `localhost` and shows nothing in production, and `ImageResponse` (Satori) **does not support `ch`126 units** — a `maxWidth: "20ch"` silently collapses and renders one word per line, through a green127 build. **Look at the generated image**, don't trust the exit code.1285. **Route the rest** to the owning skill rather than hand-fixing (table above). Don't re-implement129 `forms`' error-association pattern or `data-fetching`'s boundary ladder inside this skill.1306. **Persist**: keep `docs/ui/shadscan.json` (+ the human report if useful), update `meta.json#shadscan`,131 append `history`. **No phase bump.**132133### ⚠️ Trust calibration — what a real run actually showed134135Run against a live Next 16 + eve project: **64/100, grade D**, 59 rules (28 pass, 12 fail, 11136not-applicable, 8 advisory), 17 actionables — 9 `fix`, 7 `verify`, 4 `decide`. Every `fix` was opened in137the source. **Two of the nine survived.**138139The seven that did not, and why — this table is the skill's real content:140141| Finding | Why it was wrong |142|---|---|143| `focus-visible-not-suppressed` | the inner input's `outline-none` is deliberate; the **wrapper** carries `focus-within:ring-3` |144| `async-action-pending-state` | the parent passes `disabled={busy}` and renders a `Spinner` — pending state is **lifted**, not absent |145| `suspense-fallback-useful` | the `fallback={null}` is inside a **WebGL `<Canvas>`**, where DOM nodes are illegal; `null` is correct |146| `links-have-accessible-names` | the link is a **Base UI `render` prop**; the accessible name arrives as the parent's children |147| `empty-state-present` | the "data-backed collection" is a **static table authored by hand** |148| `validation-wired-to-form` | a chat composer needs no form library |149| `forms-have-labels` | flagged a **design-system primitive**; a primitive carries no label, its consumer does |150151**The pattern: shadscan is precise about what one file contains, and blind to composition.** It never152resolves speculatively — every string it quoted was really there — but it cannot follow a prop across a153component boundary, climb to a wrapper, see through a `render` prop, or know that a subtree renders to154WebGL instead of the DOM. **The better-composed the codebase, the more false positives it produces.**155156So trust it by *what kind of question the rule asks*:157158| Rule kind | Examples | Trust |159|---|---|---|160| **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 |161| **What is in this one file, self-contained?** | `no-positive-tabindex`, `images-have-alt` on a literal `<img>` | medium — usually right |162| **Is this component semantically complete?** | pending state, empty state, labels, focus rings, Suspense fallbacks | **low — assume wrong until the surrounding composition proves otherwise** |163164Read the **parent, the wrapper and the render target** before touching anything in the third row. Cost of165being wrong is asymmetric: a missed finding is a small gap, a "fix" applied to a false positive damages166working code and its author's trust in the gate.167168This is a *different* failure mode from `vercel-doctor`'s, not a milder one. There, `knip` invented169import graphs and flagged 58% of a codebase as dead. Here nothing is invented — the tool simply cannot170see past the file boundary, and good composition is what puts the answer on the other side of it.171172### When the fix is real but the rule stays red173174`mobile-nav-present` failed on a header that hid its whole `<nav>` below `sm` with no replacement — a175real defect: on a phone one route was unreachable. The fix was to keep two links visible and let the176wordmark step aside, verified at 375 px. **The rule still failed**: the detector looks for a trigger +177controlled panel pair, and does not recognise the "keep links visible in an explicit small-screen layout"178alternative that its own remediation text offers.179180The correct outcome is the one that feels wrong: **ship the fix, leave the rule red, write down why.**181Adding a hamburger menu for two links to turn the rule green is the textbook case of the anti-gaming rule182above. When a remediation's stated alternative is not what the detector checks, the detector is the183narrower of the two — follow the user's interest, not the score.184185## `meta.json#shadscan` block186187```jsonc188"shadscan": {189 "last_run_at": "<ISO>",190 "engine_version": "0.9.0", // report.engineVersion191 "ruleset_version": "2026.07.41", // report.rulesetVersion — the score only compares within a ruleset192 "coverage": "complete", // report.coverage.source — anything else invalidates the score193 "score": 0, // 0-100194 "grade": "D",195 "categories": { "foundation": 0, "interaction": 0, "states": 0,196 "accessibility": 0, "forms": 0, "production-polish": 0 },197 "fixed": ["not-found-route-present","social-preview-present"],198 "decisions_open": ["command-menu-present"], // disposition=decide, awaiting the user199 // Opened, read, and rejected — with the reason, so the next run doesn't re-litigate them.200 "false_positives": {201 "focus-visible-not-suppressed": "wrapper carries focus-within:ring",202 "suspense-fallback-useful": "inside a WebGL <Canvas>; null is correct"203 },204 // Real fix shipped, rule still red because the detector checks something narrower.205 "rules_left_red": {206 "mobile-nav-present": "links kept visible at <sm; detector wants a trigger+panel pair"207 },208 "routed": { "forms": ["field-errors-rendered"],209 "data-fetching": ["route-loading-boundary-present"],210 "design-md-to-app": ["theme-hydration-safe"] }211}212```213214⚠️ **Only compare scores within the same `rulesetVersion`.** A new ruleset adds rules; a score that215"dropped" across versions may be the same app measured against more checks. This is not theoretical — see216the pinning warning above; it fired on the very first real before/after. **Assert `rulesetVersion` equality217in code before reporting a delta**, rather than trusting that two runs minutes apart used the same tool.218219## dev-flow hook220221Horizontal capability — run any time. dev-flow **proposes it as a pre-deploy gate** at `feature_complete`,222completing the trio: `compliance-audit` (legal) · `vercel-doctor` (cost) · **`shadscan` (UI quality + a11y)**.223Re-run it in the `deployed` maintenance loop to catch UI regressions. It records `meta.json#shadscan` +224`history` and **never bumps `phase`**. It never *blocks* the deploy on its own — it surfaces the score and225the open decisions so the user decides.226227**In CI** the natural form is `npx --yes @shadscan/cli --json --no-interactive --fail-under <N>`, with `N`228set to the score already achieved (a ratchet), not to an aspirational number. `--fail-under` also fails on229partial coverage, which is the behaviour you want.230231## Ecosystem-first: shadscan ships its own agent skills232233The repo carries `.agents/skills/` — read them from source rather than paraphrasing:234235- **`migrate-radix-to-base/`** (SKILL.md + 9 reference files) — a Radix → **Base UI** migration guide.236 Directly relevant: our contract makes Base UI the default `ui_base`, so this is the upstream-maintained237 path for a project still on Radix. Prefer it over writing our own migration notes.238- **`shadcn/rules/`** — base-vs-radix, chat, composition, forms, icons, styling.239240Consistent with rule zero: when the tool ships the knowledge, use the tool's copy.241242## Relationship to other skills243244- **`compliance-audit`** / **`vercel-doctor`** — the sibling gates. Same shape: `feature_complete`245 pre-deploy, no phase bump, "auto-fix safe + route/flag the rest". Different surface: legal, cost, UI.246- **`transitions`** — owns `prefers-reduced-motion`. shadscan **detects** the violation, `transitions`247 **defines** the fix (tokenised motion, cheapest tier first).248- **`forms`** — owns validation wiring, error rendering and error association. Route every `forms`249 category finding there.250- **`data-fetching`** — owns Suspense boundaries, `loading.tsx` and the read ladder behind the *States*251 category.252- **`design-md-to-app`** / **`coss-ui`** — own the shell, providers, responsive layout and metadata:253 most of *Foundation*, *Interaction* and *Production Polish*.254- **`composition-patterns-guide`** — owns component anatomy (input groups, item grouping, alert anatomy).255- **`write-tests`** — complementary, not overlapping: shadscan is static and finds *missing* fundamentals;256 tests assert *behaviour*. A shadscan fix is a good prompt for a regression test.257258## Definition of Done259260- `docs/ui/shadscan.json` written; `meta.json#shadscan` populated with score, grade, per-category261 breakdown, **ruleset version** and **coverage**.262- Every `fix` actionable **opened in the source**, then either applied or declined **with the reason263 recorded** (which parent/wrapper/render target made it a false positive); every `decide` surfaced to264 the user as an open question; every `verify` checked in code.265- Any fix that is real but leaves its rule red is **shipped anyway and documented** — in the commit266 message and in `meta.json#shadscan.rules_left_red`, so the next run doesn't re-litigate it.267- The project's own gates (`verification.projectGates`) run green after the fixes — or the ones you268 couldn't run named explicitly.269- Nothing was added purely to move the number.270271## What this skill does NOT do272273- **Not a legal/privacy audit** — that's `compliance-audit`. **Not a cost audit** — that's `vercel-doctor`.274- **Doesn't design the UI** — it checks that the built UI has its fundamentals; `design-md-to-app` designs.275- **Doesn't cover mobile** — the rules are DOM/React-web; Expo/RN has no equivalent here.276- **Doesn't use `--apply`** — the routing to owning skills is the whole point.277- **Doesn't bump `phase`**, and never blocks deploy by itself.278279## Sources280281- Official site: https://www.shadscan.com/282- Repo + rules (`packages/cli/src/rules/`, 62 rules) + bundled agent skills: https://github.com/TheOrcDev/shadscan283- npm: https://www.npmjs.com/package/@shadscan/cli284285## Reference files286287- `references/contracts.md` — the `.workflow/` dev-flow contract (vendored).