Skip to content

Commit 5eea68c

Browse files
sgammonclaude
andauthored
React Doctor pass: a11y labels, Fast Refresh boundaries, stable keys, SVG precision (100/100) (#4)
* ci: add React Doctor workflow, doctor script, and agent skill Advisory PR scans + main-branch trend tracking via millionco/react-doctor@v2, a root `bun run doctor` script, and the local triage skill for agents. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(a11y): label the render-prop anchors in AppNav and MobileNav The changelog/install anchors passed to `Button render={...}` only receive their visible text at runtime, so statically they are empty links that screen-reader tooling flags as announcing nothing. Give each an aria-label bound to the same i18n message as the visible text so the two cannot drift. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ui): use stable content-derived list keys instead of array indexes Index keys reattach React state and DOM to the wrong rows if a list ever reorders or filters. Breadcrumb segments now key by href/label and stats by label; code lines and support-matrix rows, whose content can legitimately repeat, key via a new `keyed()` helper (content + occurrence counter) so keys follow the item and duplicates never collide. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * perf(ui): round Elide mark SVG coordinates to 2 decimals The mark was an Illustrator export with 6-decimal coordinates in a ~103-unit viewBox rendered 23px tall — sub-pixel precision nobody can see. Rounding all 352 numbers roughly halves the inline path data. Dot-form numbers that ride on the previous coordinate's implicit separator keep their dot or gain an explicit comma, so no coordinates merge. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(ui): move non-component exports out of component files A file that exports both a component and anything else (cva style objects, hooks) breaks Vite Fast Refresh — every edit full-reloads and wipes component state. Move the four cva variants to sibling *-variants.ts files and the useTheme/useMessages hooks next to their contexts (theme-context.ts, i18n/context.ts), repointing importers. The @elide/ui barrel re-exports everything from the new locations, so the public API is unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 5ed836a commit 5eea68c

31 files changed

Lines changed: 390 additions & 137 deletions
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
name: react-doctor
3+
description: Use when finishing a feature, fixing a bug, before committing React code, or when the user types `/doctor`, asks to scan, triage, or clean up React diagnostics. Covers lint, accessibility, bundle size, architecture. Includes a regression check and a full local-triage workflow that fetches the canonical playbook.
4+
version: "1.2.0"
5+
---
6+
7+
# React Doctor
8+
9+
Scans React codebases for security, performance, correctness, and architecture issues. Outputs a 0–100 health score.
10+
11+
## After making React code changes:
12+
13+
Run `npx react-doctor@latest --verbose --scope changed` and check the score did not regress.
14+
15+
If the score dropped, fix the regressions before committing.
16+
17+
## For general cleanup or code improvement:
18+
19+
Run `npx react-doctor@latest --verbose` (the default `--scope full`) to scan the full codebase. Fix issues by severity — errors first, then warnings.
20+
21+
## /doctor — full local triage workflow
22+
23+
When the user types `/doctor`, says "run react doctor", or asks for a full triage / cleanup pass (not just a regression check), fetch the canonical local-triage playbook and follow every step in it:
24+
25+
```bash
26+
curl --fail --silent --show-error \
27+
--header 'Cache-Control: no-cache' \
28+
https://www.react.doctor/prompts/react-doctor-agent.md
29+
```
30+
31+
The playbook is the single source of truth — a scan → filter → triage → fix → validate loop that edits the working tree directly (never commits, never opens PRs). Updating the prompt at its source updates every agent on its next fetch — no skill reinstall needed.
32+
33+
Pair it with the matching per-rule prompts at `https://www.react.doctor/prompts/rules/<plugin>/<rule>.md` (fetched on demand inside the playbook) so each fix uses the canonical, reviewer-tested recipe.
34+
35+
## Configuring or explaining rules
36+
37+
When the user wants to understand a rule, disagrees with one, or wants to disable / tune which rules run (not fix code), read [references/explain.md](references/explain.md) and follow it. Start with `npx react-doctor@latest rules explain <rule>`, then apply the narrowest control via `npx react-doctor@latest rules disable|set|category|ignore-tag …`, which edits your `doctor.config.*` (or `package.json#reactDoctor`).
38+
39+
## Command
40+
41+
```bash
42+
npx react-doctor@latest --verbose --scope changed
43+
```
44+
45+
| Flag | Purpose |
46+
| ----------------- | ---------------------------------------------------------------- |
47+
| `.` | Scan current directory |
48+
| `--verbose` | Show affected files and line numbers per rule |
49+
| `--scope changed` | Only report issues introduced vs the base branch (default: full) |
50+
| `--scope lines` | Only report issues on the changed lines |
51+
| `--score` | Output only the numeric score |
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# Explaining and configuring rules
2+
3+
Explain React Doctor rules and edit `doctor.config.*` safely. Use this when a user
4+
wants to understand a rule or change which rules run — not for fixing diagnostics
5+
(that is the main `react-doctor` skill / `/doctor`).
6+
7+
Triggers: "why did this rule fire", "I disagree with this rule", "turn this rule off",
8+
"stop flagging X", "too noisy", "disable design rules".
9+
10+
## Workflow
11+
12+
1. Identify the rule key from the diagnostic (e.g. `react-doctor/no-array-index-as-key`).
13+
2. Explain it before changing anything:
14+
15+
```bash
16+
npx react-doctor@latest rules explain react-doctor/no-array-index-as-key
17+
```
18+
19+
3. Pick the narrowest control that matches the user's intent (see decision guide).
20+
4. Apply it with a `rules` subcommand (edits your `doctor.config.*` or `package.json#reactDoctor` in place, preserving other fields and formatting).
21+
5. Validate the change did what they wanted:
22+
23+
```bash
24+
npx react-doctor@latest --verbose --diff
25+
```
26+
27+
## Commands
28+
29+
```bash
30+
npx react-doctor@latest rules list # every rule + its effective severity
31+
npx react-doctor@latest rules list --configured # only what your config changed
32+
npx react-doctor@latest rules list --category Performance # filter by category
33+
npx react-doctor@latest rules explain <rule> # why it matters + how to configure
34+
npx react-doctor@latest rules disable <rule> # rule never runs
35+
npx react-doctor@latest rules enable <rule> # turn back on at its recommended severity
36+
npx react-doctor@latest rules set <rule> warn # off | warn | error
37+
npx react-doctor@latest rules category "React Native" off # whole category
38+
npx react-doctor@latest rules ignore-tag design # skip a rule family (design, test-noise, …)
39+
npx react-doctor@latest rules unignore-tag design
40+
```
41+
42+
Rule references accept the full key (`react-doctor/no-danger`), the bare id (`no-danger`), or a legacy key (`react/no-danger`).
43+
44+
## Decision guide
45+
46+
Match the control to the intent — prefer the narrowest one:
47+
48+
- **User disagrees with one rule / it's a false positive for them**`rules disable <rule>` (sets `rules.<key> = "off"`; the rule stops running everywhere). This is the default for "I don't want this rule".
49+
- **Rule is fine but wrong severity**`rules set <rule> warn` or `rules set <rule> error`.
50+
- **A disabled-by-default rule they want on**`rules enable <rule>`.
51+
- **A whole area is unwanted** (e.g. all React Native rules) → `rules category "<Category>" off`.
52+
- **A behavioral family is noisy** (`design`, `test-noise`, `migration-hint`) → `rules ignore-tag <tag>`.
53+
- **Keep it locally but hide from PR comment / score / CI gate only** → do NOT disable. Edit `surfaces` in your config (`surfaces.prComment.excludeRules`, `surfaces.score.excludeTags`, `surfaces.ciFailure.excludeCategories`). The rule still shows in local `cli` output.
54+
55+
How the layers combine: `ignore.tags` disables every rule carrying that tag **before** linting, so a tagged rule stays off even if `rules`/`categories` set it to `warn`/`error` (a rule-level override cannot re-enable a tag-ignored rule). For rules that aren't tag-disabled, `rules` overrides `categories` overrides the rule's default. `surfaces` is visibility-only and never changes whether a rule runs.
56+
57+
## Config shape
58+
59+
Config lives in `doctor.config.ts` (or `.js`/`.mjs`/`.cjs`/`.json`/`.jsonc`), or the `reactDoctor` key in `package.json`. The `rules` commands edit whichever exists — TS/JS edits preserve formatting (via magicast) — and create `doctor.config.json` when none does, stamping `$schema`:
60+
61+
```ts
62+
// doctor.config.ts
63+
export default {
64+
rules: { "react-doctor/no-array-index-as-key": "off" },
65+
categories: { "React Native": "warn" },
66+
ignore: { tags: ["design"] },
67+
};
68+
```
69+
70+
## Educating the user
71+
72+
When explaining a rule, lead with the "Why it matters" guidance from `rules explain` and, when they want depth, the per-rule recipe at `https://www.react.doctor/prompts/rules/<plugin>/<rule>.md`. Only after they understand it should you offer to disable it — many "bad" rules are catching real issues.

.github/workflows/react-doctor.yml

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# React Doctor — finds security, performance, correctness, accessibility,
2+
# bundle-size, and architecture issues in React codebases.
3+
#
4+
# Docs: https://www.react.doctor/ci
5+
# Source: https://github.com/millionco/react-doctor
6+
7+
name: React Doctor
8+
9+
on:
10+
# Scans the PR's changed files and posts a sticky summary comment listing only the new issues introduced relative to the merge base of the target branch.
11+
pull_request:
12+
types: [opened, synchronize, reopened, ready_for_review]
13+
# Scans `main` on every push to track the health-score trend and catch regressions that slipped past PR review.
14+
push:
15+
branches: ["main"]
16+
17+
permissions:
18+
contents: read
19+
pull-requests: write
20+
issues: write
21+
statuses: write
22+
23+
# Cancels any in-flight scan for the same PR (or branch, on push) the moment a new commit arrives, so reviewers only ever see the latest run.
24+
concurrency:
25+
group: react-doctor-${{ github.event.pull_request.number || github.ref }}
26+
cancel-in-progress: true
27+
28+
jobs:
29+
react-doctor:
30+
runs-on: ubuntu-latest
31+
steps:
32+
# fetch-depth: 0 gives React Doctor the full git history it needs to find the merge base with the target branch. Without it a shallow checkout has no merge base, so PR runs can't compare against the base and fall back to reporting every issue in the changed files (pre-existing ones included) instead of only the ones the PR introduced.
33+
- uses: actions/checkout@v5
34+
with:
35+
fetch-depth: 0
36+
37+
- uses: millionco/react-doctor@v2
38+
# Advisory by default: React Doctor reports findings on every PR — a
39+
# sticky summary comment, inline review comments, and a commit status
40+
# with the health score — but never fails the check, so it won't red-X
41+
# a teammate's PR on day one. When your team trusts the signal, graduate
42+
# the gate: uncomment the block below and set blocking to "error" (fail
43+
# on new error-severity findings) or "warning" (fail on any finding).
44+
# Full reference: https://www.react.doctor/ci
45+
# with:
46+
# blocking: error # Gate level: "none" (advisory, the default) | "warning" | "error"
47+
# scope: full # On PRs, scan the whole project instead of just changed files
48+
# comment: false # Disable the sticky PR summary comment
49+
# review-comments: false # Disable inline review comments on changed lines
50+
# commit-status: false # Disable the commit status (score + counts, links to the run)
51+
# version: "0.4.0" # Pin to a specific react-doctor version instead of "latest"
52+
# directory: apps/web # Scan a sub-directory (default: ".")
53+
# project: "web,admin" # In a monorepo, scan specific workspace project(s)

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@
1717
"build-storybook": "turbo run build --filter=@elide/tokens --filter=@elide/ui && bun run --filter=@elide/storybook build-storybook",
1818
"chromatic": "bun run --filter @elide/storybook chromatic",
1919
"changeset": "changeset",
20-
"release": "changeset publish"
20+
"release": "changeset publish",
21+
"doctor": "npx react-doctor@latest"
2122
},
2223
"devDependencies": {
2324
"@changesets/cli": "^2.31.0",

packages/ui/src/components/ai-actions.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import * as React from "react";
22
import { Check, Copy, ExternalLink, Sparkles } from "lucide-react";
33
import { cn } from "../lib/utils";
4-
import { useMessages } from "../i18n/provider";
4+
import { useMessages } from "../i18n/context";
55

66
/**
77
* AiActions — the "Use with AI" panel in the docs right rail: copy the page as

packages/ui/src/components/app-nav.tsx

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { ChevronDown, Globe, History, Search, Sparkles, Sun } from "lucide-react
33
import { cn } from "../lib/utils";
44
import { Button } from "./button";
55
import { ElideLogo } from "./elide-logo";
6-
import { useMessages } from "../i18n/provider";
6+
import { useMessages } from "../i18n/context";
77

88
/**
99
* AppNav — the 56px top nav bar present on every docs page: brand + "DOCS"
@@ -120,12 +120,23 @@ export function AppNav({
120120
<ChevronDown aria-hidden className="h-[13px] w-[13px]" />
121121
</button>
122122

123-
<Button variant="changelog" size="sm" render={<a href={changelogHref} />}>
123+
{/* The render-prop anchors get their visible text merged in by Button at
124+
runtime; the aria-label keeps them labelled for static analysis and
125+
mirrors the visible text exactly. */}
126+
<Button
127+
variant="changelog"
128+
size="sm"
129+
render={<a href={changelogHref} aria-label={m.appNav.changelog} />}
130+
>
124131
<History aria-hidden className="h-[15px] w-[15px]" />
125132
{m.appNav.changelog}
126133
</Button>
127134

128-
<Button variant="gradient" size="sm" render={<a href={installHref} />}>
135+
<Button
136+
variant="gradient"
137+
size="sm"
138+
render={<a href={installHref} aria-label={m.appNav.install} />}
139+
>
129140
{m.appNav.install}
130141
</Button>
131142
</nav>
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { cva } from "class-variance-authority";
2+
3+
/**
4+
* Badge class variants. Kept out of badge.tsx so that file exports only
5+
* components and Fast Refresh can preserve state on edit.
6+
*/
7+
export const badgeVariants = cva(
8+
"inline-flex items-center gap-1.5 rounded-full font-semibold leading-none",
9+
{
10+
variants: {
11+
variant: {
12+
neutral: "border border-border text-muted-foreground",
13+
primary: "text-[var(--primary-emphasis)] [background:var(--primary-soft)]",
14+
supported: "text-[var(--eld-success-strong)] [background:color-mix(in_oklab,var(--eld-success-strong)_14%,transparent)]",
15+
partial: "text-[var(--eld-warning-strong)] [background:color-mix(in_oklab,var(--eld-warning-strong)_14%,transparent)]",
16+
missing: "text-muted-foreground [background:var(--muted)]",
17+
},
18+
size: {
19+
sm: "px-2 py-0.5 text-[10px]",
20+
md: "px-2.5 py-1 text-xs",
21+
},
22+
},
23+
defaultVariants: { variant: "neutral", size: "md" },
24+
},
25+
);
Lines changed: 2 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,12 @@
11
import * as React from "react";
2-
import { cva, type VariantProps } from "class-variance-authority";
2+
import { type VariantProps } from "class-variance-authority";
33
import { cn } from "../lib/utils";
4+
import { badgeVariants } from "./badge-variants";
45

56
/**
67
* Badge — leaf primitive. Includes the API-reference status tones
78
* (supported / partial / missing) used across the docs reference pages.
89
*/
9-
const badgeVariants = cva(
10-
"inline-flex items-center gap-1.5 rounded-full font-semibold leading-none",
11-
{
12-
variants: {
13-
variant: {
14-
neutral: "border border-border text-muted-foreground",
15-
primary: "text-[var(--primary-emphasis)] [background:var(--primary-soft)]",
16-
supported: "text-[var(--eld-success-strong)] [background:color-mix(in_oklab,var(--eld-success-strong)_14%,transparent)]",
17-
partial: "text-[var(--eld-warning-strong)] [background:color-mix(in_oklab,var(--eld-warning-strong)_14%,transparent)]",
18-
missing: "text-muted-foreground [background:var(--muted)]",
19-
},
20-
size: {
21-
sm: "px-2 py-0.5 text-[10px]",
22-
md: "px-2.5 py-1 text-xs",
23-
},
24-
},
25-
defaultVariants: { variant: "neutral", size: "md" },
26-
},
27-
);
28-
2910
export interface BadgeProps
3011
extends React.HTMLAttributes<HTMLSpanElement>,
3112
VariantProps<typeof badgeVariants> {
@@ -41,5 +22,3 @@ export function Badge({ className, variant, size, dot, children, ...props }: Bad
4122
</span>
4223
);
4324
}
44-
45-
export { badgeVariants };

packages/ui/src/components/breadcrumbs.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export function Breadcrumbs({ segments, className, ...props }: BreadcrumbsProps)
2323
{segments.map((segment, i) => {
2424
const isLast = i === segments.length - 1;
2525
return (
26-
<li key={`${segment.label}-${i}`} className="flex items-center gap-1.5">
26+
<li key={segment.href ?? segment.label} className="flex items-center gap-1.5">
2727
{isLast ? (
2828
<span aria-current="page" className="font-medium text-foreground">
2929
{segment.label}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { cva } from "class-variance-authority";
2+
3+
/**
4+
* Button class variants. Kept out of button.tsx so that file exports only
5+
* components and Fast Refresh can preserve state on edit.
6+
*
7+
* `gradient` is the Elide brand CTA (the "Install" button). `changelog` is the
8+
* violet-outlined affordance used in the docs nav.
9+
*/
10+
export const buttonVariants = cva(
11+
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg font-medium transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
12+
{
13+
variants: {
14+
variant: {
15+
primary: "bg-primary text-primary-foreground hover:bg-primary/90",
16+
gradient: "text-white [background:var(--eld-gradient-brand)] hover:opacity-95",
17+
outline: "border border-border bg-transparent text-foreground hover:bg-[var(--hover)]",
18+
ghost: "bg-transparent text-muted-foreground hover:bg-[var(--hover)] hover:text-foreground",
19+
changelog:
20+
"border text-foreground [border-color:var(--eld-accent-violet)] [background:color-mix(in_oklab,var(--eld-accent-violet)_10%,transparent)] [box-shadow:0_0_0_1px_color-mix(in_oklab,var(--eld-accent-violet)_22%,transparent),0_0_16px_-4px_color-mix(in_oklab,var(--eld-accent-violet)_55%,transparent)] hover:[background:color-mix(in_oklab,var(--eld-accent-violet)_16%,transparent)]",
21+
},
22+
size: {
23+
sm: "h-8 px-3 text-xs",
24+
md: "h-9 px-4 text-sm",
25+
icon: "h-9 w-9 p-0",
26+
"icon-sm": "h-8 w-8 p-0",
27+
},
28+
},
29+
defaultVariants: { variant: "primary", size: "md" },
30+
},
31+
);

0 commit comments

Comments
 (0)