…the rule blocking
All 118 `selector-max-type` warnings were the same nudge: prefer classes over
element-type selectors. Decided each one instead of silencing the rule.
63 were avoidable and got a class:
- `Heading` now emits its level class (`.h1`–`.h6`) for every heading, not only
for `elementType` ones, so the stylesheet stops selecting `h1`–`h6`. The
paired `:where(hN), :where(.hN)` selectors collapse to the class; `:where()`
keeps specificity at zero either way, so nothing moves.
- `Popover`/`Tooltip` class the tip `<svg>` they render themselves (`.tipIcon`).
- Overlay's centering wrapper gets a `.viewport` class, and its layout moves
from the duplicated `> div` blocks in `Modal`/`LightBox` into
`Overlay.module.scss`. react-aria's own modal wrapper is selected through its
default `react-aria-Modal` class, which it keeps because Flow passes it no
className. Same for `react-aria-Input` (`ComboBox`), `react-aria-Heading` and
`react-aria-CalendarGrid` (`Calendar`).
- Flow's own literal elements get classes: `CalendarHeader`'s `<header>`,
`CodeBlock`'s `<pre>`/`<code>`, the `<ul>` in `LinkListTunnelExit`,
`HeaderNavigation`/`TabNavigation`'s `<ul>` and their `wrapWith` `<li>`,
`DateRangeInput`'s wrapper div, the docs wireframe chart bars.
- Existing classes replace equivalent element selectors in `MarkdownEditor`,
`NumberField`, `SearchField`, `Slider`, `ListItemView`, `LayoutCard`,
`AnchorNavigation`, `LiveCodeEditor` and the docs landing page.
- `MessageThread` sets `list-style: none` on the list instead of its items —
the property is inherited, so the result is identical.
55 are legitimate and now carry a `stylelint-disable` comment naming why that
element has no class: react-markdown's output (`Markdown`), raw HTML the
children bring along (`Text`), consumer-supplied `Form`/`Separator`/`Button`/
`Image` children, third-party svgs (recharts, tabler, prism), and the cases
where a class would change the matched set (`Combine`'s `:has(label)` also
covers react-aria's Checkbox/Switch roots; `Section`/`SectionHeader` key on the
heading's rendered element, not its level).
Deleted one dead rule: `DateRangeInput`'s `> span` — the span is a grandchild,
so it never matched, and `> div` already supplies the same declarations.
With the count at 0 the rule moves from `warning` to `error`. It could not
regress-guard anything before, because 118 standing warnings hid any new one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
All 118
selector-max-typewarnings are gone and the rule is nowerror. Theywere decided one at a time, not swept: 59 got a class, 58 are legitimate and
carry a
stylelint-disablecomment naming why that element has no class, 1 wasa dead rule and is deleted.
The rule could not guard anything before — 118 standing warnings hid any new
one. Now every legitimate case is opted out explicitly, so a fresh report is a
real finding.
Read this hunk first: the
ColumnLayoutclass nameListItemView.module.scsshad.columnLayout > div { align-items: center }. Ireplaced the
> divwith:global(.flow--column-layout--column-layout)— andthat class does not exist. The class-name generator
(
dev/vite/cssModuleClassNameGenerator.ts) drops the suffix when it equalsthe component name, so
ColumnLayout's.columnLayoutcompiles toflow--column-layout, notflow--column-layout--column-layout. The selectormatched nothing,
align-items: centerwas lost, and the list heading sat 12 pxoff.
To be clear about attribution: this was a defect in my refactor, not a
pre-existing one. On
mainthe> divselector worked. The local visualsuite caught it and the second commit fixes it.
What is worth taking away is the footgun, not the fix: a
:global(.flow--…)selector that names a class which never existed fails completely silently —
no lint error, no type error, no console warning, and the docs site and
Storybook keep rendering. Nothing in the repo checks that these names resolve.
I wrote a throwaway check that greps every
:global(.flow--…)reference out ofpackages/components/srcandapps/docs/srcand asserts each one appears inthe built
dist/css/all.css. Two results:main, all 39 real references resolve — there is no existingbreakage of this kind, so a gate added now would start green.
Proposing that as a repeatable gate separately; out of scope here.
Buckets
components/Modal/Modal.module.scsscomponents/Heading/Heading.module.scsscomponents/Markdown/Markdown.module.scsscomponents/MarkdownEditor/MarkdownEditor.module.scsscomponents/Text/Text.module.scsscomponents/Section/Section.module.scsscomponents/Section/components/SectionHeader/SectionHeader.module.scsscomponents/Popover/Popover.module.scsscomponents/Tooltip/Tooltip.module.scsscomponents/Calendar/Calendar.module.scsscomponents/CodeBlock/CodeBlock.module.scsscomponents/LightBox/LightBox.module.scssdocs/lib/liveCode/components/LiveCodeEditor/LiveCodeEditor.module.csscomponents/List/components/ListItemView/ListItemView.module.scssdocs/app/layout.module.scssdocs/app/page.module.scsscomponents/Chat/Chat.module.scsscomponents/Combine/Combine.module.scsscomponents/ComboBox/ComboBox.module.scsscomponents/HeaderNavigation/HeaderNavigation.module.scsscomponents/Navigation/Navigation.module.scsscomponents/NumberField/NumberField.module.scsscomponents/TabNavigation/TabNavigation.module.scssdocs/app/components/_components/wireframe/primitives.module.scsscomponents/DateRangePicker/components/DateRangeInput/DateRangeInput.module.scsscomponents/Avatar/Avatar.module.scsscomponents/CheckboxGroup/CheckboxGroup.module.scsscomponents/CartesianChart/CartesianChart.module.scsscomponents/ContextMenu/ContextMenu.module.scsscomponents/DonutChart/DonutChart.module.scsscomponents/LayoutCard/LayoutCard.module.scsscomponents/MessageThread/MessageThread.module.scsscomponents/SearchField/SearchField.module.scsscomponents/Slider/Slider.module.scsscomponents/Table/Table.module.scssdocs/app/_components/layout/AnchorNavigation/AnchorNavigation.module.scssdocs/app/_components/layout/Header/Header.module.scsscomponents/Navigation/components/NavigationGroup/NavigationGroup.module.scsscomponents/Tabs/components/TabTitle/TabTitle.module.scssdocs/app/components/_components/wireframe/wireframes/wireframes.module.scsscomponents/List/components/Header/components/SearchField/SearchField.module.scssWhy the legitimate bucket is legitimate
Each disable names its own reason inline. They fall into five groups:
Markdownstyles react-markdown'soutput, and consumers can replace any renderer through
components— a Flowclass would not survive that.
Textstyles raw<ul>/<ol>/<blockquote>its children bring along (
Markdownnests exactly that inside aText).Together: 16 of the 58.
Form,Separator,Button,Imageandraw
<form>/<img>/<header>/<button>placed by the caller.FormandSeparatorare plain FCs, so a props context cannot even inject a class intothem. Covers
Modal,Section,Chat,ContextMenu,Navigation,Avatar,LightBox,Table,DonutChart,TabTitle,ListItemView.CartesianChart), tabler icon svgsin the docs wireframes, prism-react-renderer's
<pre>inLiveCodeEditor.Combine's:has(label)alsocovers react-aria's Checkbox and Switch roots, so
Label's class is notequivalent.
CheckboxGroup'ssvgdeliberately covers consumer icons in thelabel too.
NumberField'ssvg:not(.coarsePointerIcon)hidesButton's ownpending/success/failure icons, which share no class with the chevrons. The
list header's
SearchFieldmust match whateversearch.renderrenders.SectionandSectionHeaderkey on theheading's rendered element, not on its
level. AHeadingwithelementTyperenders a span/p that carries the level class but must notclaim heading spacing — so the class is not a drop-in here even now that
Headingalways emits it.What I deliberately did not refactor
Modal's five remaining selectors.> form,[role="dialog"] > form,headerandbuttonare consumer markup (above).> spanis theinteresting one:
Heading's spans do carry classes, but.flow--heading--heading-content:emptyalready setsdisplay: noneatexactly the specificity a class selector here would reach — the two would tie
and source order across two CSS modules would decide. Left as an element
selector on purpose.
CodeBlock's> pre/> code. I classed them, and the visual suitecaught it: a
flow--class makes the global reset's*:where([class*="flow--"] [class*="flow--"]) { font: inherit }apply, whichreplaces the UA monospace metrics and visibly reflows the code text. Reverted
with that reason recorded. Whether
--code-block--font-familyshould winthere is a UX call, not a lint cleanup.
Section/SectionHeader(11 together). Element-type matching is theintent, and both
:has(hN)andhN.headingwould also shift specificity.Not worth a rewrite of the section spacing rules.
layout.module.scss> h3/> h4andHeader.module.scssh3.Both look dead in the current DOM (an MDX wrapper makes the headings
grandchildren; the
Headerrule sits behind a class the component neverapplies). Switching them to a class would activate rules that match nothing
today — a visual change. Disabled with the reason; the dead-rule cleanup is a
separate question.
Notable changes on the classed side
Headingnow emits its level class (.h1–.h6) for every heading, notonly for
elementTypeones. The paired:where(hN), :where(.hN)selectorscollapse to the class;
:where()keeps specificity at zero either way, sonothing moves. Nothing in the repo selected
.flow--heading--h*before..viewportclass, and its layout movesout of the duplicated
> divblocks inModalandLightBoxintoOverlay.module.scss— the two had byte-identical declaration blocks, minusModal's
pointer-events: none.className:react-aria-Modal(Overlay's modal wrapper),react-aria-Input(ComboBox),react-aria-Headingandreact-aria-CalendarGrid(Calendar). No markup change at all in thosecases.
Calendar.module.scssalready used this pattern forreact-aria-CalendarCell.MessageThreadsetslist-style: noneon the list instead of its items —the property is inherited, so the result is identical.
DateRangeInput's> span. The span is a grandchild,so it never matched, and
> divalready supplies the same declarations.Gates
Run locally on webkit:
pnpm lintreact-hooks/exhaustive-depsin files this PR does not touchpnpm nx test:compile componentspnpm nx test:unit componentspnpm nx test:browser componentspnpm nx test:visual remote-react-componentsPlus a probe that the rule really blocks: a temporary
spanselector inText.module.scssnow fails with1 error, not a warning.Also verified that every
:global(.flow--…)name this PR references actuallyexists in the built
dist/css/all.css— that check is what caught theColumnLayoutmistake below.Visual diffs I saw and fixed
The first visual run had 3 failing scenarios (6 tests, Local + Remote). Both
real ones are fixed in the second commit; no baseline was updated.
CodeBlock— 3624 px (ratio 0.01). Real: the code text reflowed becauseclassing
<pre>/<code>pulled in the global reset'sfont: inherit.Fixed by reverting to element selectors.
Listedge cases — column layout — real: the heading lostalign-items: centerand shifted up 12 px. Cause and attribution inRead this hunk first
above. Fixed in the second commit.
Initials— 296 px (0.032%), bbox 20×20 px on the 😄 avatar, max channeldelta 49. Not caused by this PR:
git difftouches nothing underInitials/,Flex/orsrc/styles/, and the only Avatar change is acomment. Emoji glyph antialiasing under load (1-min load average was 9–12
during that run). It passes in the second run at load 5.
run-visual-testsis on the PR so the Linux baselines get verified in CI too.No screenshots are committed and
update-screenshotsis deliberately notapplied — that is a call for once the suite is green in CI.
fixes #3021
🤖 Generated with Claude Code