Skip to content

feat(rules): add 10 motion, accessibility, and design lint rules#825

Merged
rayhanadev merged 9 commits into
mainfrom
add-design-motion-a11y-rules
Jun 16, 2026
Merged

feat(rules): add 10 motion, accessibility, and design lint rules#825
rayhanadev merged 9 commits into
mainfrom
add-design-motion-a11y-rules

Conversation

@aidenybai

@aidenybai aidenybai commented Jun 15, 2026

Copy link
Copy Markdown
Member

Why

Adds 10 statically-lintable design rules derived from a cross-resource design knowledge base (anti-slop design languages, Tailwind/shadcn systems, motion sources, OKLCH/contrast, Apple HIG, jsx-a11y). Each was deduped against React Doctor's full existing inventory (not just design/), so these fill real gaps — notably the Tailwind-class siblings of inline-only motion rules and a real WCAG-ratio contrast check.

Before / after examples:

// motion — flagged
<div className="transition-all duration-200 hover:translate-y-1" />
<div className="transition-[height] duration-300" />
// after
<div className="transition-transform duration-200 hover:translate-y-1" />

// a11y — flagged (browser-blocked, hostile)
<video autoPlay loop src="hero.mp4" />
// after
<video autoPlay muted loop playsInline src="hero.mp4" />

// a11y — flagged: gray-400 on white ≈ 2.5:1 (< 4.5:1)
<span style={{ color: "#9ca3af", backgroundColor: "#fff" }}>Balance</span>

What changed

Motion

  • no-transition-allextended to also flag the Tailwind transition-all class (was inline-style-only).
  • no-tailwind-layout-transitionnew: arbitrary transition-[width|height|top|left|right|bottom|margin|padding].

Accessibility

  • no-autoplay-without-muted<video autoPlay> / <audio autoPlay> missing muted (skips dynamic autoplay, spreads, truthy/dynamic muted).
  • no-uninformative-aria-labelaria-label value that is a content-free element-type word ("icon", "button", "image", …).
  • no-target-blank-without-rel<a target="_blank">/<area> missing rel="noopener"/noreferrer (skips spreads + dynamic rel).
  • no-low-contrast-inline-style — computes the real WCAG 2.1 ratio from co-located inline color + backgroundColor; flags < 4.5:1 (< 3:1 large/bold). Sound-only: skips alpha, var(), transparent, gradients, unresolvable colors.

Design / Tailwind hygiene

  • no-redundant-display-classblock on <div>, inline on <span>, etc. (skips variant-prefixed + flex/grid/hidden).
  • prefer-truncate-shorthandoverflow-hidden text-ellipsis whitespace-nowraptruncate.
  • no-full-viewport-widthw-screen / w-[100vw] / inline 100vww-full.
  • no-svg-currentcolor-with-fill-classfill/stroke="currentColor" fighting a fill-*/stroke-* class.

Adds a reusable get-wcag-contrast-ratio util + WCAG constants; in-house a11y rules registered in RULES_NOT_PORTED_FROM_EXTERNAL so customRulesOnly keeps them. Registry regenerated via codegen (388 rules). Changeset included (patch).

Validation

  • 77 co-located tests pass across all 10 rules (adversarial: variant prefixes, dynamic/spread attributes, custom components, multi-word labels, alpha colors, large-vs-normal text thresholds).
  • RDE / OSS eval: 500 repos, 0 false positives. Ran local mode (required for the oxlint AST-rule layer). ~2,562 hits across 12 of the 13 rules; every sampled hit validated as a true positive against real source (full per-rule table in the comment below). The 13th (no-svg-currentcolor-with-fill-class) surfaced no matching pattern in the corpus — it's a narrow, unit-tested case. Notable real bugs caught: a white-on-white 1.00:1 contrast violation in a calcom email template, and no-redundant-display-class was tag-audited across all 103 hits. The strict tailwind:4 gate held — no-deprecated-tailwind-class fired only on confirmed-v4 repos.

Test plan

  • pnpm --filter oxlint-plugin-react-doctor gen
  • pnpm exec vp test run (the 10 new *.test.ts + rule-registry.test.ts) — 77 passing
  • pnpm --filter oxlint-plugin-react-doctor typecheck
  • pnpm exec vp lint + pnpm exec vp fmt on the new files

Note

Low Risk
Changes are additive static analysis with conservative skips and version-gated Tailwind rules; no production runtime or auth/data-path changes, only new warn-level diagnostics for consumers.

Overview
Adds 13 lint capabilities to oxlint-plugin-react-doctor (plus an extension to an existing rule), wired through codegen-updated rule-registry and a patch changeset across core + plugin + CLI.

Motion / performance: no-transition-all now flags Tailwind transition-all (not only inline transition / transitionProperty). New no-tailwind-layout-transition catches arbitrary transition-[…] on layout properties (width, height, margin, etc.).

Accessibility: New rules for autoplay without muted, uninformative static aria-label values, target="_blank" without noopener/noreferrer, and WCAG 2.1 contrast on co-located inline color + background (with skips for spreads, alpha, var(), gradients, and ambiguous background shorthands). In-house a11y rules are listed in RULES_NOT_PORTED_FROM_EXTERNAL so customRulesOnly still ships them.

Design / Tailwind hygiene: Rules for redundant default display classes, truncate shorthand, 100vw / w-screen overflow, conflicting SVG currentColor vs fill-*/stroke-* classes, arbitrary text-[Npx] font sizes, prefer-dvh-over-vh (requires tailwind:3.4), and Tailwind v4 renames via no-deprecated-tailwind-class (requires new tailwind:4 capability).

Core: @react-doctor/core gains tailwind:4 only when Tailwind major ≥ 4 is confirmed (stricter than optimistic tailwind:3.4 for unparseable versions), with tests. Shared helpers: get-class-name-tokens, get-wcag-contrast-ratio, WCAG constants in design.ts, and has-jsx-spread-attribute centralized (also used by no-uncontrolled-input).

Reviewed by Cursor Bugbot for commit 74cf07b. Bugbot is set up for automated code reviews on this repo. Configure here.

@pkg-pr-new

pkg-pr-new Bot commented Jun 15, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/eslint-plugin-react-doctor@825
npm i https://pkg.pr.new/oxlint-plugin-react-doctor@825
npm i https://pkg.pr.new/react-doctor@825

commit: 74cf07b

@github-actions

github-actions Bot commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

No React Doctor issues found. 🎉

Reviewed by React Doctor for commit 74cf07b.

Comment thread packages/core/src/runners/oxlint/capabilities.ts
classNameValue
.split(/\s+/)
.filter((token) => token.length > 0)
.map((token) => token.split(":").pop() ?? token);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important prefix breaks token matching

Low Severity

getClassNameTokens leaves Tailwind’s leading ! on utilities, so tokens like !flex-shrink-0 or !transition-all no longer match equality or startsWith checks in rules that depend on normalized base names.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2945fec. Configure here.

node,
message:
"`overflow-hidden text-ellipsis whitespace-nowrap` is exactly what the `truncate` utility does — collapse the three classes into `truncate`.",
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Truncate rule ignores existing truncate

Low Severity

The rule fires whenever overflow-hidden, text-ellipsis, and whitespace-nowrap all appear, but never checks for an existing truncate class. Class lists that already include truncate plus those three utilities still get told to collapse into truncate, which is redundant noise.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c173c29. Configure here.

isNodeOfType(property.value, "Literal") &&
typeof property.value.value === "string" &&
property.value.value.startsWith("all")
property.value.value.trim().startsWith("all")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

transitionProperty prefix matches too broadly

Low Severity

Inline transition and transitionProperty both use trim().startsWith("all"). That fits transition shorthand (all 200ms), but transitionProperty values are property names, so custom idents like alloy or allocation can be reported even though they are not all.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c173c29. Configure here.

@rayhanadev rayhanadev force-pushed the add-design-motion-a11y-rules branch from c173c29 to 7b59646 Compare June 16, 2026 03:50
aidenybai and others added 9 commits June 16, 2026 00:54
Derived from a cross-resource design reference (every principle from the
design knowledge base), deduped against the full existing rule inventory.

Motion: extend no-transition-all to the Tailwind `transition-all` class
(was inline-only) and add no-tailwind-layout-transition for arbitrary
`transition-[<layout-prop>]`.

Accessibility: no-autoplay-without-muted, no-uninformative-aria-label,
no-target-blank-without-rel, and no-low-contrast-inline-style (computes the
real WCAG 2.1 ratio from co-located inline color + backgroundColor).

Design/Tailwind hygiene: no-redundant-display-class, prefer-truncate-shorthand,
no-full-viewport-width, and no-svg-currentcolor-with-fill-class.

All register via codegen (388 rules); 77 co-located tests pass; typecheck,
lint, and format clean.
- no-low-contrast-inline-style: when the inline style has no `fontSize`,
  the text may be sized large via a class (`text-5xl`), so fall back to the
  lenient 3:1 large-text threshold instead of 4.5:1 to avoid flagging large
  text that only needs 3:1. Stricter 4.5:1 applies only when the size is a
  visible normal size.
- no-redundant-display-class: `<li>` defaults to `display: list-item`, not
  `block`, so `block` on an `<li>` is meaningful — removed `li` from the
  block-default set.

Adds regression tests for both.
- no-low-contrast-inline-style: use a solid `background` shorthand color as
  the background (was skipped entirely, missing low-contrast pairs); skip only
  gradients/images/ambiguous (both backgroundColor + background). Also treat a
  string `fontWeight: "700"` as bold for the large-text threshold.
- no-transition-all: match `transition-all` as a whole Tailwind token (segment
  after variant prefixes) so compound classes like `transition-all-custom` are
  not falsely flagged.
- no-svg-currentcolor-with-fill-class: exclude stroke-WIDTH utilities
  (`stroke-2`, `stroke-[1.5]`) — they set thickness, not color — so they no
  longer false-conflict with `stroke="currentColor"`.

Adds regression tests for each.
- no-deprecated-tailwind-class: flag Tailwind v4 renames (bg-gradient-* →
  bg-linear-*, flex-shrink-* → shrink-*, flex-grow-* → grow-*,
  overflow-ellipsis → text-ellipsis). Gated on a new `tailwind:4` capability.
- no-arbitrary-px-font-size: `text-[13px]` → rem so text scales with the
  user's root font size (px stays fine for border-*/outline-*).
- prefer-dvh-over-vh: `h-screen`/`min-h-screen`/`h-[100vh]` → dvh (tailwind:3.4).

Adds `tailwind:4` capability to @react-doctor/core and extracts a shared
`get-class-name-tokens` util (variant-aware token splitting), reused by
no-transition-all and no-svg-currentcolor-with-fill-class.
- no-tailwind-layout-transition: match transition-[…] property names exactly
  (comma-split + set) so SVG `stroke-width` / `border-width` — which merely
  contain "width" — are no longer flagged as HTML layout thrash.
- no-full-viewport-width: drop `max-w-*` / `maxWidth` (a defensive cap, not the
  overflow footgun); keep `w-screen`/`w-[100vw]`/`width`/`minWidth`.
- prefer-dvh-over-vh: drop `max-h-*` / `maxHeight` (a valid height cap, e.g. a
  scrollable modal); keep `h-screen`/`min-h-screen`/`height`/`minHeight`.
- no-deprecated-tailwind-class: only rename `bg-gradient-to-*` → `bg-linear-to-*`
  (v4 radial/conic are `bg-radial`/`bg-conic`, not `bg-linear-radial`).

Adds regression tests for each false positive.
- no-svg-currentcolor-with-fill-class: only treat UNPREFIXED `fill-*`/`stroke-*`
  color classes as conflicts; `hover:fill-blue-600` / `dark:fill-white` (state-
  gated, no static conflict with the base `fill="currentColor"`) are no longer
  flagged — a very common icon pattern.
- no-low-contrast-inline-style: reject 4-arg `rgb(0,0,0,0.5)` / slash-form alpha
  (was judged opaque), and bail when the style object contains a `{...spread}`
  that could override the colors. Tag `test-noise` to match sibling no-tiny-text.
- no-full-viewport-width: drop the redundant `category: "Architecture"` override
  (it's the design-bucket default).
- Extract `has-jsx-spread-attribute` util; use it in the two a11y rules.

Adds regression tests for the variant-prefixed svg classes, rgb-alpha, opaque
rgb, and the style-spread bail.
Consolidate duplication surfaced by an adversarial deslop pass; no behavior
change (the same diagnostics fire for the same inputs):

- no-autoplay-without-muted: fold isStaticallyTrue/isStaticallyFalse into one
  tri-state resolveStaticBoolean, mirroring the existing parseJsxValue idiom
- no-svg-currentcolor-with-fill-class: collapse the duplicated fill/stroke
  branches into a single loop over the two paint attributes
- no-low-contrast-inline-style: hoist the repeated `properties` fallback
- no-uncontrolled-input: drop the local hasJsxSpreadAttribute copy in favor of
  the branch's new shared utils/has-jsx-spread-attribute, completing the extraction

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`overflow-clip` is a current Tailwind utility for `overflow: clip` (v3.1+,
still present in v4) — it was never renamed to `text-clip`, which sets the
unrelated `text-overflow: clip`. The stray mapping produced an incorrect
"renamed in v4" suggestion (Cursor Bugbot). Only `overflow-ellipsis ->
text-ellipsis` is a real rename; keep that. Adds a regression test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…d:4 gate

`tailwind:4` gates no-deprecated-tailwind-class, which tells users a class was
renamed/removed in v4. The optimistic-on-null policy (assume latest for an
unparseable spec like `workspace:*`) is right for the suggestion rule behind
`tailwind:3.4`, but for a deprecation rule it surfaces confidently-wrong
"renamed in v4" warnings on a v3 project. Require a CONFIRMED parsed major >= 4
for tailwind:4 — favor a false negative over a false positive (Cursor Bugbot).
`tailwind:3.4` stays optimistic. Adds an unparseable-version regression test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@rayhanadev rayhanadev force-pushed the add-design-motion-a11y-rules branch from 7b59646 to 74cf07b Compare June 16, 2026 07:56

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

There are 4 total unresolved issues (including 3 from previous reviews).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 74cf07b. Configure here.

const value = token.slice(prefix.length);
if (value === "" || value === "current") return false;
if (/^\d/.test(value) || /^\[\d/.test(value)) return false;
return true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-color fill stroke false positives

Medium Severity

hasColorUtility treats any unprefixed fill-* or stroke-* token whose suffix is not numeric as a color class. Tailwind utilities such as fill-opacity-*, fill-rule-*, stroke-linecap-*, stroke-linejoin-*, and stroke-dash* also match, so common SVG markup with fill="currentColor" or stroke="currentColor" plus those classes gets incorrect conflict warnings.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 74cf07b. Configure here.

@rayhanadev

Copy link
Copy Markdown
Member

RDE / OSS evaluation — 500 repos, 0 false positives

Ran the react-doctor-evals harness in local mode (required — the cloud pool currently fires only the project-level analyzers, not the oxlint AST-rule layer, so it would report a misleading 0). Corpus: 500 OSS repos → 75,733 total diagnostics; ~2,562 hits across 12 of the 13 new/changed rules. Every sampled hit was validated against actual source.

rule hits verdict
no-arbitrary-px-font-size 1501 ✅ TP — real text-[Npx]
no-transition-all (extended) 593 ✅ TP — Tailwind class + inline transition:"all…"
prefer-dvh-over-vh 152 ✅ TP — h-screen / 100vh
no-redundant-display-class 103 ✅ TP — all 103 tags audited: div/fieldset/ul/p/nav=block, a/span/code=inline
no-target-blank-without-rel 55 ✅ TP — target="_blank"/target={'_blank'} without rel
no-deprecated-tailwind-class 50 ✅ TP — overflow-ellipsis on confirmed-v4 repos
prefer-truncate-shorthand 41 ✅ TP — all three classes co-located
no-full-viewport-width 40 ✅ TP — w-screen / 100vw
no-tailwind-layout-transition 12 ✅ TP — transition-[width] etc.
no-uninformative-aria-label 9 ✅ TP — "Link", "Button", "button" on icon buttons / empty <th>
no-autoplay-without-muted 3 ✅ TP — <video autoPlay …> without muted
no-low-contrast-inline-style 3 ✅ TP — 1.00:1 white-on-white ×2, 2.91:1 gray-on-light
no-svg-currentcolor-with-fill-class 0 — no matching pattern in corpus (narrow; unit-tested)

Notable real bugs caught

  • no-low-contrast-inline-style flagged color:#ffffff on background:#FFFFFF (exact 1.00:1) in calcom / cal.diy email templates — genuinely unreadable, and confirms the WCAG 2.1 math.
  • no-autoplay-without-muted caught <video autoPlay playsInline /> and <video autoPlay loop /> (no muted) in usememos/memos and toeverything/AFFiNE.
  • no-target-blank-without-rel caught reverse-tabnabbing-prone links in payloadcms/payload, dubinc/dub, lobehub/lobe-chat (including the target={'_blank'} expression-container form).

Confidence checks

  • The strict tailwind:4 gate (the Bugbot fix in this PR) held: no-deprecated-tailwind-class fired only on confirmed-v4 repos, and the overflow-clip fix held (only the real overflow-ellipsis rename was flagged).
  • no-redundant-display-class semantics audited exhaustively — every flagged tag genuinely has the stated default display, and standalone-token matching avoids inline-block traps.

Result: 0 false positives → no rule changes needed. For the rule-validate eval table: 500 repos scanned · ~2,562 target diagnostics · 0 FPs (validated ~25 hits across all firing rules + full-tag audit on the trickiest rule).

@rayhanadev rayhanadev merged commit 09dde18 into main Jun 16, 2026
20 checks passed
@rayhanadev rayhanadev deleted the add-design-motion-a11y-rules branch June 16, 2026 21:21
@github-actions github-actions Bot mentioned this pull request Jun 16, 2026
@rayhanadev rayhanadev restored the add-design-motion-a11y-rules branch June 16, 2026 23:30
@rayhanadev

Copy link
Copy Markdown
Member

Reverted in #849 and reopened as #850 (this PR was squash-merged, so GitHub won't reopen it directly — #850 is a fresh PR off the restored add-design-motion-a11y-rules branch with the identical changes). Merge #849 first, then #850 re-lands the batch.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants