Skip to content
Open
Show file tree
Hide file tree
Changes from 9 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
34 changes: 34 additions & 0 deletions .changeset/design-motion-a11y-rules.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
"oxlint-plugin-react-doctor": patch
"@react-doctor/core": patch
"react-doctor": patch
---

Add 10 design-quality lint rules distilled from a cross-resource design reference, spanning motion performance, accessibility, and Tailwind/JSX hygiene.

**Motion**

- **`no-transition-all`** (extended) — now also flags the Tailwind `transition-all` class (was inline-`style`-only). Animating every property that changes includes expensive layout properties and instant ones like focus rings; name the properties (`transition-colors`, `transition-transform`).
- **`no-tailwind-layout-transition`** — Tailwind arbitrary `transition-[width|height|top|left|right|bottom|margin|padding]`, which animates layout properties the browser recomputes every frame. Animate `transform`/`opacity` instead.

**Accessibility**

- **`no-autoplay-without-muted`** — `<video autoPlay>` / `<audio autoPlay>` missing `muted` (sound-on autoplay is hostile to users and browser-blocked). Skips dynamic `autoPlay`, spreads, and truthy/dynamic `muted`.
- **`no-uninformative-aria-label`** — an `aria-label` whose value is a content-free element-type word (`"icon"`, `"button"`, `"image"`, `"link"`, …) that tells screen-reader users nothing about the action.
- **`no-target-blank-without-rel`** — `<a target="_blank">` (and `<area>`) missing `rel="noopener"`/`noreferrer` (reverse tabnabbing). Skips spreads and dynamic `rel`.
- **`no-low-contrast-inline-style`** — computes the real WCAG 2.1 contrast ratio from a co-located inline `color` + `backgroundColor` and flags pairs below 4.5:1 (3:1 for large/bold text). Only fires on opaque, statically-resolvable colors (skips alpha, `var()`, gradients).

**Design / Tailwind hygiene**

- **`no-redundant-display-class`** — a display utility matching the element's default (`block` on a `<div>`, `inline` on a `<span>`); skips variant-prefixed and meaningful displays (`flex`, `grid`, `hidden`).
- **`prefer-truncate-shorthand`** — `overflow-hidden text-ellipsis whitespace-nowrap` collapses to the single `truncate` utility.
- **`no-full-viewport-width`** — `w-screen` / `w-[100vw]` / inline `100vw`, which overflows horizontally when a scrollbar is visible; prefer `w-full` / `width: 100%`.
- **`no-svg-currentcolor-with-fill-class`** — `fill="currentColor"` / `stroke="currentColor"` fighting a `fill-*` / `stroke-*` color class (the class silently wins); keep one, or use `fill-current`.

**Tailwind canonicalization** (distilled from ui.sh's canonicalize-tailwind guidance)

- **`no-deprecated-tailwind-class`** — Tailwind v4 renamed/removed `bg-gradient-*` → `bg-linear-*`, `flex-shrink-*` → `shrink-*`, `flex-grow-*` → `grow-*`, `overflow-ellipsis` → `text-ellipsis`. Gated on a new `tailwind:4` capability so v3 projects are unaffected.
- **`no-arbitrary-px-font-size`** — `text-[13px]` doesn't scale with the user's root font size; use rem (`text-[0.8125rem]`). Pixels stay fine for `border-*`/`outline-*`.
- **`prefer-dvh-over-vh`** — `h-screen`/`min-h-screen`/`h-[100vh]` overflow under mobile browser chrome; prefer `dvh` (`h-dvh`/`min-h-dvh`). Gated on `tailwind:3.4`.

Also adds a `tailwind:4` project capability to `@react-doctor/core` for version-gated Tailwind rules.
14 changes: 12 additions & 2 deletions packages/core/src/runners/oxlint/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,11 +83,21 @@ export const buildCapabilities = (project: ProjectInfo): ReadonlySet<string> =>
if (project.tailwindVersion !== null) {
capabilities.add("tailwind");
const tailwind = parseTailwindMajorMinor(project.tailwindVersion);
// HACK: when version is unparseable (dist-tag, workspace protocol),
// assume latest so version-gated rules still fire.
// HACK: when the version is unparseable (dist-tag, workspace protocol),
// `isTailwindAtLeast` optimistically assumes latest so the suggestion rule
// behind `tailwind:3.4` (prefer-dvh-over-vh) still fires.
if (isTailwindAtLeast(tailwind, { major: 3, minor: 4 })) {
capabilities.add("tailwind:3.4");
}
// `tailwind:4` gates `no-deprecated-tailwind-class`, which tells users a
// class was renamed/removed in v4. On an unparseable spec we can't prove
// the project is on v4, and a confidently-wrong "deprecated" warning on a
// v3 codebase (where `flex-shrink-0` etc. are still correct) is worse than
// staying silent — so require a CONFIRMED major >= 4 here, deliberately
// stricter than the optimistic gate above.
if (tailwind !== null && isTailwindAtLeast(tailwind, { major: 4, minor: 0 })) {
capabilities.add("tailwind:4");
}
}

if (project.zodVersion !== null) {
Expand Down
22 changes: 22 additions & 0 deletions packages/core/tests/build-capabilities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,28 @@ describe("buildCapabilities", () => {
expect(capabilities.has("zod:4")).toBe(false);
});

it("emits `tailwind`, `tailwind:3.4`, and `tailwind:4` for a Tailwind 4 project", () => {
const capabilities = buildCapabilities({ ...baseProject, tailwindVersion: "^4.0.0" });
expect(capabilities.has("tailwind")).toBe(true);
expect(capabilities.has("tailwind:3.4")).toBe(true);
expect(capabilities.has("tailwind:4")).toBe(true);
});

it("emits `tailwind:3.4` but not `tailwind:4` for a Tailwind 3.4 project", () => {
const capabilities = buildCapabilities({ ...baseProject, tailwindVersion: "^3.4.1" });
expect(capabilities.has("tailwind:3.4")).toBe(true);
expect(capabilities.has("tailwind:4")).toBe(false);
});

it("stays optimistic for `tailwind:3.4` but withholds `tailwind:4` when the version is unparseable", () => {
const capabilities = buildCapabilities({ ...baseProject, tailwindVersion: "workspace:*" });
expect(capabilities.has("tailwind")).toBe(true);
expect(capabilities.has("tailwind:3.4")).toBe(true);
// A deprecation rule must not fire on an unprovable version — a v3 project
// would otherwise get confidently-wrong "renamed in v4" warnings.
expect(capabilities.has("tailwind:4")).toBe(false);
});

it("emits `nextjs:15` capability for Next.js 15+ projects", () => {
const capabilities = buildCapabilities({
...baseProject,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ const EFFECT_RULES_PORTED_FROM_EXTERNAL = new Set([
// into `a11y/` would silently disappear for users who narrow scope.
const RULES_NOT_PORTED_FROM_EXTERNAL = new Set([
"prefer-html-dialog",
"no-autoplay-without-muted",
"no-uninformative-aria-label",
"no-target-blank-without-rel",
"dialog-has-accessible-name",
"no-create-ref-in-function-component",
"no-call-component-as-function",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ export const COLOR_CHROMA_THRESHOLD = 30;

export const TINY_TEXT_THRESHOLD_PX = 12;

// WCAG 2.1 contrast minimums. Normal text needs 4.5:1; "large" text
// (>=24px regular, or >=18.66px / 14pt bold) and icons need 3:1.
export const WCAG_CONTRAST_NORMAL_MIN = 4.5;
export const WCAG_CONTRAST_LARGE_MIN = 3;
export const LARGE_TEXT_MIN_PX = 24;
export const LARGE_BOLD_TEXT_MIN_PX = 18.66;
export const BOLD_FONT_WEIGHT_MIN = 700;

export const WIDE_TRACKING_THRESHOLD_EM = 0.05;

export const LONG_TRANSITION_DURATION_THRESHOLD_MS = 1000;
Expand Down
Loading
Loading