Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions .claude/skills/react-doctor/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
---
name: react-doctor
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.
version: "1.2.0"
---

# React Doctor

Scans React codebases for security, performance, correctness, and architecture issues. Outputs a 0–100 health score.

## After making React code changes:

Run `npx react-doctor@latest --verbose --scope changed` and check the score did not regress.

If the score dropped, fix the regressions before committing.

## For general cleanup or code improvement:

Run `npx react-doctor@latest --verbose` (the default `--scope full`) to scan the full codebase. Fix issues by severity — errors first, then warnings.

## /doctor — full local triage workflow

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:

```bash
curl --fail --silent --show-error \
--header 'Cache-Control: no-cache' \
https://www.react.doctor/prompts/react-doctor-agent.md
```

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.

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.

## Configuring or explaining rules

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`).

## Command

```bash
npx react-doctor@latest --verbose --scope changed
```

| Flag | Purpose |
| ----------------- | ---------------------------------------------------------------- |
| `.` | Scan current directory |
| `--verbose` | Show affected files and line numbers per rule |
| `--scope changed` | Only report issues introduced vs the base branch (default: full) |
| `--scope lines` | Only report issues on the changed lines |
| `--score` | Output only the numeric score |
72 changes: 72 additions & 0 deletions .claude/skills/react-doctor/references/explain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Explaining and configuring rules

Explain React Doctor rules and edit `doctor.config.*` safely. Use this when a user
wants to understand a rule or change which rules run — not for fixing diagnostics
(that is the main `react-doctor` skill / `/doctor`).

Triggers: "why did this rule fire", "I disagree with this rule", "turn this rule off",
"stop flagging X", "too noisy", "disable design rules".

## Workflow

1. Identify the rule key from the diagnostic (e.g. `react-doctor/no-array-index-as-key`).
2. Explain it before changing anything:

```bash
npx react-doctor@latest rules explain react-doctor/no-array-index-as-key
```

3. Pick the narrowest control that matches the user's intent (see decision guide).
4. Apply it with a `rules` subcommand (edits your `doctor.config.*` or `package.json#reactDoctor` in place, preserving other fields and formatting).
5. Validate the change did what they wanted:

```bash
npx react-doctor@latest --verbose --diff
```

## Commands

```bash
npx react-doctor@latest rules list # every rule + its effective severity
npx react-doctor@latest rules list --configured # only what your config changed
npx react-doctor@latest rules list --category Performance # filter by category
npx react-doctor@latest rules explain <rule> # why it matters + how to configure
npx react-doctor@latest rules disable <rule> # rule never runs
npx react-doctor@latest rules enable <rule> # turn back on at its recommended severity
npx react-doctor@latest rules set <rule> warn # off | warn | error
npx react-doctor@latest rules category "React Native" off # whole category
npx react-doctor@latest rules ignore-tag design # skip a rule family (design, test-noise, …)
npx react-doctor@latest rules unignore-tag design
```

Rule references accept the full key (`react-doctor/no-danger`), the bare id (`no-danger`), or a legacy key (`react/no-danger`).

## Decision guide

Match the control to the intent — prefer the narrowest one:

- **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".
- **Rule is fine but wrong severity** → `rules set <rule> warn` or `rules set <rule> error`.
- **A disabled-by-default rule they want on** → `rules enable <rule>`.
- **A whole area is unwanted** (e.g. all React Native rules) → `rules category "<Category>" off`.
- **A behavioral family is noisy** (`design`, `test-noise`, `migration-hint`) → `rules ignore-tag <tag>`.
- **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.

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.

## Config shape

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`:

```ts
// doctor.config.ts
export default {
rules: { "react-doctor/no-array-index-as-key": "off" },
categories: { "React Native": "warn" },
ignore: { tags: ["design"] },
};
```

## Educating the user

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.
53 changes: 53 additions & 0 deletions .github/workflows/react-doctor.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# React Doctor — finds security, performance, correctness, accessibility,
# bundle-size, and architecture issues in React codebases.
#
# Docs: https://www.react.doctor/ci
# Source: https://github.com/millionco/react-doctor

name: React Doctor

on:
# 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.
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
# Scans `main` on every push to track the health-score trend and catch regressions that slipped past PR review.
push:
branches: ["main"]

permissions:
contents: read
pull-requests: write
issues: write
statuses: write

# 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.
concurrency:
group: react-doctor-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

jobs:
react-doctor:
runs-on: ubuntu-latest
steps:
# 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.
- uses: actions/checkout@v5
with:
fetch-depth: 0

- uses: millionco/react-doctor@v2
# Advisory by default: React Doctor reports findings on every PR — a
# sticky summary comment, inline review comments, and a commit status
# with the health score — but never fails the check, so it won't red-X
# a teammate's PR on day one. When your team trusts the signal, graduate
# the gate: uncomment the block below and set blocking to "error" (fail
# on new error-severity findings) or "warning" (fail on any finding).
# Full reference: https://www.react.doctor/ci
# with:
# blocking: error # Gate level: "none" (advisory, the default) | "warning" | "error"
# scope: full # On PRs, scan the whole project instead of just changed files
# comment: false # Disable the sticky PR summary comment
# review-comments: false # Disable inline review comments on changed lines
# commit-status: false # Disable the commit status (score + counts, links to the run)
# version: "0.4.0" # Pin to a specific react-doctor version instead of "latest"
# directory: apps/web # Scan a sub-directory (default: ".")
# project: "web,admin" # In a monorepo, scan specific workspace project(s)
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
"build-storybook": "turbo run build --filter=@elide/tokens --filter=@elide/ui && bun run --filter=@elide/storybook build-storybook",
"chromatic": "bun run --filter @elide/storybook chromatic",
"changeset": "changeset",
"release": "changeset publish"
"release": "changeset publish",
"doctor": "npx react-doctor@latest"
},
"devDependencies": {
"@changesets/cli": "^2.31.0",
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/components/ai-actions.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as React from "react";
import { Check, Copy, ExternalLink, Sparkles } from "lucide-react";
import { cn } from "../lib/utils";
import { useMessages } from "../i18n/provider";
import { useMessages } from "../i18n/context";

/**
* AiActions — the "Use with AI" panel in the docs right rail: copy the page as
Expand Down
17 changes: 14 additions & 3 deletions packages/ui/src/components/app-nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { ChevronDown, Globe, History, Search, Sparkles, Sun } from "lucide-react
import { cn } from "../lib/utils";
import { Button } from "./button";
import { ElideLogo } from "./elide-logo";
import { useMessages } from "../i18n/provider";
import { useMessages } from "../i18n/context";

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

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

<Button variant="gradient" size="sm" render={<a href={installHref} />}>
<Button
variant="gradient"
size="sm"
render={<a href={installHref} aria-label={m.appNav.install} />}
>
{m.appNav.install}
</Button>
</nav>
Expand Down
25 changes: 25 additions & 0 deletions packages/ui/src/components/badge-variants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { cva } from "class-variance-authority";

/**
* Badge class variants. Kept out of badge.tsx so that file exports only
* components and Fast Refresh can preserve state on edit.
*/
export const badgeVariants = cva(
"inline-flex items-center gap-1.5 rounded-full font-semibold leading-none",
{
variants: {
variant: {
neutral: "border border-border text-muted-foreground",
primary: "text-[var(--primary-emphasis)] [background:var(--primary-soft)]",
supported: "text-[var(--eld-success-strong)] [background:color-mix(in_oklab,var(--eld-success-strong)_14%,transparent)]",
partial: "text-[var(--eld-warning-strong)] [background:color-mix(in_oklab,var(--eld-warning-strong)_14%,transparent)]",
missing: "text-muted-foreground [background:var(--muted)]",
},
size: {
sm: "px-2 py-0.5 text-[10px]",
md: "px-2.5 py-1 text-xs",
},
},
defaultVariants: { variant: "neutral", size: "md" },
},
);
25 changes: 2 additions & 23 deletions packages/ui/src/components/badge.tsx
Original file line number Diff line number Diff line change
@@ -1,31 +1,12 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { type VariantProps } from "class-variance-authority";
import { cn } from "../lib/utils";
import { badgeVariants } from "./badge-variants";

/**
* Badge — leaf primitive. Includes the API-reference status tones
* (supported / partial / missing) used across the docs reference pages.
*/
const badgeVariants = cva(
"inline-flex items-center gap-1.5 rounded-full font-semibold leading-none",
{
variants: {
variant: {
neutral: "border border-border text-muted-foreground",
primary: "text-[var(--primary-emphasis)] [background:var(--primary-soft)]",
supported: "text-[var(--eld-success-strong)] [background:color-mix(in_oklab,var(--eld-success-strong)_14%,transparent)]",
partial: "text-[var(--eld-warning-strong)] [background:color-mix(in_oklab,var(--eld-warning-strong)_14%,transparent)]",
missing: "text-muted-foreground [background:var(--muted)]",
},
size: {
sm: "px-2 py-0.5 text-[10px]",
md: "px-2.5 py-1 text-xs",
},
},
defaultVariants: { variant: "neutral", size: "md" },
},
);

export interface BadgeProps
extends React.HTMLAttributes<HTMLSpanElement>,
VariantProps<typeof badgeVariants> {
Expand All @@ -41,5 +22,3 @@ export function Badge({ className, variant, size, dot, children, ...props }: Bad
</span>
);
}

export { badgeVariants };
2 changes: 1 addition & 1 deletion packages/ui/src/components/breadcrumbs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export function Breadcrumbs({ segments, className, ...props }: BreadcrumbsProps)
{segments.map((segment, i) => {
const isLast = i === segments.length - 1;
return (
<li key={`${segment.label}-${i}`} className="flex items-center gap-1.5">
<li key={segment.href ?? segment.label} className="flex items-center gap-1.5">
{isLast ? (
<span aria-current="page" className="font-medium text-foreground">
{segment.label}
Expand Down
31 changes: 31 additions & 0 deletions packages/ui/src/components/button-variants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { cva } from "class-variance-authority";

/**
* Button class variants. Kept out of button.tsx so that file exports only
* components and Fast Refresh can preserve state on edit.
*
* `gradient` is the Elide brand CTA (the "Install" button). `changelog` is the
* violet-outlined affordance used in the docs nav.
*/
export const buttonVariants = cva(
"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",
{
variants: {
variant: {
primary: "bg-primary text-primary-foreground hover:bg-primary/90",
gradient: "text-white [background:var(--eld-gradient-brand)] hover:opacity-95",
outline: "border border-border bg-transparent text-foreground hover:bg-[var(--hover)]",
ghost: "bg-transparent text-muted-foreground hover:bg-[var(--hover)] hover:text-foreground",
changelog:
"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)]",
},
size: {
sm: "h-8 px-3 text-xs",
md: "h-9 px-4 text-sm",
icon: "h-9 w-9 p-0",
"icon-sm": "h-8 w-8 p-0",
},
},
defaultVariants: { variant: "primary", size: "md" },
},
);
30 changes: 2 additions & 28 deletions packages/ui/src/components/button.tsx
Original file line number Diff line number Diff line change
@@ -1,41 +1,17 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { type VariantProps } from "class-variance-authority";
import { cn } from "../lib/utils";
import { buttonVariants } from "./button-variants";

/**
* Button — leaf primitive.
*
* Styled entirely from @elide/tokens (bg-primary, text-primary-foreground, …).
* `gradient` is the Elide brand CTA (the "Install" button). `changelog` is the
* violet-outlined affordance used in the docs nav.
*
* The `render` prop mirrors Base UI's composition model (replaces Radix
* `asChild`): pass an element and the button's props/classes merge onto it —
* e.g. `<Button render={<a href="/install" />}>Install</Button>`.
*/
const buttonVariants = cva(
"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",
{
variants: {
variant: {
primary: "bg-primary text-primary-foreground hover:bg-primary/90",
gradient: "text-white [background:var(--eld-gradient-brand)] hover:opacity-95",
outline: "border border-border bg-transparent text-foreground hover:bg-[var(--hover)]",
ghost: "bg-transparent text-muted-foreground hover:bg-[var(--hover)] hover:text-foreground",
changelog:
"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)]",
},
size: {
sm: "h-8 px-3 text-xs",
md: "h-9 px-4 text-sm",
icon: "h-9 w-9 p-0",
"icon-sm": "h-8 w-8 p-0",
},
},
defaultVariants: { variant: "primary", size: "md" },
},
);

export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
Expand Down Expand Up @@ -65,5 +41,3 @@ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
},
);
Button.displayName = "Button";

export { buttonVariants };
Loading
Loading