UI
The four regions every tool screen is built from, what belongs in each, and the overlay vocabulary that differs between web and mobile.
A tool screen — a screener, a trends board, a value-bet list — is the same four regions on both platforms. What differs is how the two filter regions are presented, because a phone has no room for a persistent panel.
Icons and imagery
Every filter option and data row carries a crest, headshot or symbol — and the fallback chain when it cannot.
Overlays
Dialog on web, sheet on mobile, and why there is no Dialog in the native kit.
The four regions
| Region | Holds | Never holds |
|---|---|---|
| Nav sidebar | Categories and the tools inside them | Filters |
| Top controls | Page-wide view, search, filter, fixture and sort controls | Result cards, tables or other feature content |
| Filter panel | Every other filter for this tool | Anything required to understand the board |
| Content | The board, inline | Chrome, or a card around the board |
The dividing question between the top controls and the panel is "would a reader need to see this without opening anything?" Search and the primary dimension belong at the top; a bookmaker allow-list does not.
Every panel control lives in a titled group
Nothing in the filter panel renders on its own. A control needs a
FilterGroup, and the group needs a FilterGroupLabel — which is also where
its icon comes from, resolved from the title by filterSymbol (see
icons and imagery).
<FullToolFilters>
<FilterGroup>
<FilterGroupLabel>Sources</FilterGroupLabel> {/* title + resolved icon */}
<FilterGroupContent>
<FilterToggleGroup id="bookmakers" options={bookmakers} … />
</FilterGroupContent>
</FilterGroup>
</FullToolFilters>A bare control in the panel has no name, so the reader has to infer what a list of logos filters. It also cannot collapse, so it costs its full height in a column that is a stack of eleven other groups.
Two failures this rule catches, both of which were live:
- A panel with no groups at all. Player cards had its date control on the bar and pinned in the footer, and nothing in the panel body — so opening "All filters" showed an empty column.
- A component that returns a bare control. A sub-component is free to
return one, as long as every call site wraps it. If no call site does, the
file is dead — that is how an orphaned
bookmaker-filter.tsxsurvived, exporting aFilterToggleGroupnothing rendered.
The bar and the footer are the exceptions, and only because they are one row tall: a pill carries its own label, and a footer control sits beside Save and Reset. Everything in the scrolling panel is grouped.
Never put a dropdown inside a collapsible
A filter group is already a disclosure. You opened Statistic to find out
what the statistic is — and a closed Select inside it answers with a trigger
you have to open too. The group then costs two clicks to read, and shows one
word when it is open, which is the same as being shut.
So the control depends on where it renders, not on how many options it has:
| Where | Single choice renders as | Why |
|---|---|---|
| Filter panel group | FilterChoice — the options, shown | The group is the disclosure; nothing else should hide |
| Important-filters bar | FilterSelect — a trigger | One line of height, and the pill already summarises the value |
| Panel footer | FilterSelect — a trigger | Same: one row, pinned |
FilterChoice shows a filter field of its own once the list reaches eight
options, because a visible list of forty is its own kind of hiding. Below that
it is rows, each with its icon, and a tick on the selected one.
The same reasoning rules out the other nested disclosures: no Popover, no
Accordion, and no second FilterGroup inside a group. A group opens once and
everything inside it is then legible.
The schema route does this for you
AdvancedFilters picks the rendering from the slot it is filling — a
kind: "select" filter is a trigger on the bar and a shown list in the panel,
from one declaration. Only hand-rolled panels can get this wrong.
Stack page-wide controls above content
A tool may need more than one control row. Keep each control board as a separate
FilterBarRow, in this order:
- The Important filters row: search, primary dimension and panel actions.
- A league, team or category rail.
- A fixture rail or other dependent control.
- Subpage tabs, last — nearest the board they switch.
Rows stay one line tall and scroll on their own horizontal axis. Do not wrap a long chip rail and do not place search and chips in one overflowing row. The shell draws a divider before every additional row, including rows that arrive through a portal.
Subpage tabs go last, small and left-aligned
They were first, centred and at text-base, which made them the loudest thing
on the page and put two rows of chrome between a tab and the board it changes.
They are the least-used control up there — a reader picks a board once and
then works in it — so they read as the quietest:
- Last, immediately above the content.
- Left-aligned, packed at their label width rather than stretched across the row or centred.
size="sm", which putsvariant="line"tabs at the sametext-xsas the subsection labels and the chip rail rather than a step above them.
ScopeSwitch does all three. size="lg" stays for tabs rendered in the page
BODY, where the tabs are the heading — prop-screener's all/watchlist row is
the example.
The bar does not stick
The header does; the filter bar does not. Four stacked rows pinned under a 64px header took a third of a short viewport before the board began, and the row a reader reaches for mid-scroll is the pills — which are in the filter panel too, so nothing is lost by letting the bar scroll away.
Horizontal overflow always has an edge cue
Every horizontal rail shows an edge fade while content remains outside its
viewport. Use HorizontalScroll from @statshub/ui-web; it observes the real
scroll range, shows the right fade at the starting edge, adds the left fade
after scrolling, and removes each fade when that edge is reached.
import { HorizontalScroll } from "@statshub/ui-web/components/horizontal-scroll";
<HorizontalScroll className="flex gap-2 scrollbar-hide">
{leagues.map((league) => (
<LeagueChip key={league.id} league={league} />
))}
</HorizontalScroll>;Do not add a permanent gradient over a rail. A fade with no overflow, or one
that remains after the last item, falsely advertises hidden content. The raw
Table primitive and DataTable already use HorizontalScroll; do not wrap
either in a second horizontal scroller. scroll-fade-x remains the migration
class for an existing scroll container that cannot yet use the component.
Page-wide controls never render inside feature content. This includes view
switchers, search fields, filter chips, league and fixture rails, and result
sorting. Put them in ImportantOnlyToolFilters or ToolFilterRows; the content
tree should start with the title, status and results.
This rule does not move controls whose scope is one content component. Table pagination and column visibility, a card's actions, and a dialog's form stay beside the content they operate on. Moving those to the page top would hide their scope rather than clarify it.
Content is inline, never in a card
The content region is already a region. It sits inside AppWorkspace between
the control stack and the edge of the screen, and a reader can see where the
board begins without a border being drawn around it. Wrapping it draws a second
boundary a few pixels inside the first and buys nothing:
- The board reads as inset twice, once by the workspace and once by the card.
- An empty state inside a card is a box, inside a box, around a sentence.
- The card's own padding fights the workspace inset every other tool aligns to, so a table in a card stops lining up with a table that is not in one.
Render the board, its empty state and its pagination row straight into the content region.
// Wrong — a border around the region that already has one.
<div className="mx-auto rounded-xl border-2 p-4">
<ResultsTable />
<Pagination />
</div>
// Right.
<ResultsTable />
<Pagination />An empty state centres, which means every wrapper above it is a flex column
Empty and ErrorState are already flex-1 items-center justify-center. That
is the whole of their centring, and it does nothing on its own: flex-1 needs a
flex parent, and a flex parent needs height to hand out. Break the chain
anywhere between AppWorkspace and the state and it silently falls back to its
own min-h and sits at the top of a tall board — no error, no warning, just a
sentence pinned under the filter bar with an empty screen beneath it.
The chain is four links, and all four have to hold:
<AppWorkspace> {/* flex min-h-[calc(100svh-4rem)] flex-1 flex-col */}
<div className="flex min-h-0 flex-1 flex-col"> {/* the content region */}
<DataTable /> {/* flex flex-col, and flex-1 WHILE showing a state */}
<Empty /> {/* flex-1 items-center justify-center */}Two things worth saying out loud about the third line:
flex-1only while it is showing a state. A table that stretched would push its last row away from the rows above it, leaving a gap mid-board.min-h-0on the content region, not justflex-1. A flex child's defaultmin-height: autorefuses to shrink below its content, so a long table stops the region from scrolling.
Do not fix a state that will not centre by giving it a taller min-h. That
moves the sentence down without centring it, and it is wrong again at the next
viewport height. Find the link in the chain that is not a flex column.
This is about the container, not the rows
A card is still the right shape for a repeated unit inside the content — one bet, one fixture, one player — where its border separates a row from the row beneath it. What must never be wrapped is the content region itself.
Use DataTable for primary row-based results
A route-level board whose main content is a set of comparable rows uses the
shared DataTable from apps/statshub-web/src/components/reusable/data-table.tsx.
Referees is the reference implementation. Team Screener, Prop Screener, Super
Sub Heroes, Data Visualizer event results and Odds Converter follow the same
contract.
Every primary data table has:
- column definitions created with
createDataTableColumnHelper; - sorting, pagination, selection and column visibility controlled through
nuqs, so a reload or copied URL preserves the view; - loading skeletons inside the table body, with error and empty states rendered alone in the content region instead of inside table chrome;
- a stable
getRowIdderived from domain identifiers, never the row index; - column visibility in
ToolBarActionsand navigation in the table footer; - one horizontally scrollable table on compact screens, not a second card representation with a different schema.
Use the raw Table primitive only for compact nested matrices, such as a
fixture comparison inside a detail panel. Cards remain correct for repeated
units, charts remain charts, and calculators keep their purpose-built layout.
The rule applies when the route's primary result is tabular; it does not turn
every collection into a table.
Keeping this boundary explicit prevents each feature from inventing its own loading placement, mobile schema, sort control and pagination language.
No caption counting the results
"4 trends found" tells a reader what the rows under it already tell them, and it costs a line at the top of every tool. Nothing states the size of a result set in prose — not above the board, not under it:
// Wrong.
<span>{pagination.totalCount} trends found</span>
<span>Showing {from} to {to} of {total} entries</span>
// Right — the rows are the count.The reader still gets the number where it does work: the pagination control
keeps Page {currentPage} of {totalPages}, because that one is navigation
rather than a summary — it says what pressing Next will do.
An empty state is not a count and stays. "No value bets found" is the only thing on screen when the board is empty; it is content, not a caption.
A number is a slider and a box, never one of them
A slider alone cannot express 2.5 when the step is 1, and it cannot be typed
into. A box alone gives no sense of the range — is 40 a lot? The three numeric
controls in filter-controls.tsx used to pick one each, so the same quantity
looked like a different kind of thing depending on which panel you opened.
They now all render both, and share one NumberField:
| Control | Shape |
|---|---|
FilterRange | one two-handle slider, a box at each end |
FilterNumberRange | one slider and one box per side, stacked |
FilterThreshold | − / box / +, with the slider under it |
Two rules the shared NumberField encodes, both learned from getting them
wrong:
- Typed digits commit on blur and on Enter, not on keystroke. Mid-typing,
2.and""are legitimate things to have in the box. Pushing either upstream snaps the slider to zero and bounces the caret. - Dragging is local until release. The slider holds its own value while
the handle is down and calls
onValueChangeon commit, so a drag across a server-backed filter fires one request rather than forty.
FilterNumberRange keeps its bare pair of inputs when min and max are not
both given: an unbounded server filter has no track to draw.
Two numbers are not always a range
FilterNumberRange is two independent values, which is why it draws two
single-handle sliders rather than one two-handle range. The build-ups
calculator puts Player 1 on from and Player 2 on to, and Player 1 is
routinely the larger — a range slider would silently reorder them.
Selects say whether they take one answer or several
FilterChoice takes multiple. Single is the default, and the rows are
radios; with multiple they are checkboxes and value is an array. It is one
prop rather than a second component because the search field, the
keep-the-selection-visible rule and the row chrome are the parts worth
sharing.
Reach for FilterToggleGroup instead when the group wants a select-all.
"Is this still the default" for a multi-valued control compares members, not
order — picking Goals then Shots is the same filter as Shots then Goals, and
a join() comparison would light the active count up for a click sequence.
Domain repetition lives in components/helpers/
Three directories, three jobs:
| Directory | Holds |
|---|---|
components/reusable/ | generic controls — FilterRange knows about numbers, not odds |
components/helpers/ | this product's recurring shapes — OddsFilter knows odds run 1.01 to 10 |
components/filters/ | one tool's panel, assembled from both |
A helper is config-first and deliberately narrow. OddsFilter takes a value
and a handler; the scale, the step, the two-decimal format and the group label
are not parameters, because a second opinion about what an odds range is is
exactly the thing being prevented.
Every sidebar group also crosses the ToolFilter helper boundary. Feature
modules use ToolFilter, ToolFilterLabel, ToolFilterContent and
ToolFilterSubsection; they do not import the design-system group primitives
directly. A regression test discovers filter modules and FullToolFilters
consumers wherever they live, so moving a panel outside components/filters/
does not bypass the rule.
tool is the id prefix — <OddsFilter tool="prop-screener" /> registers
prop-screener-odds. Ids reach the saved-view library and the panel's active
count, so they stay stable and unique per tool.
Where a helper covers something AdvancedFilters also renders, it ships in
both forms — <OddsFilter> for panels written as JSX, oddsFilterDef() for
panels declared as data — reading the same constants, so the two cannot
disagree.
The four so far, each found by counting rather than guessing — and every one of the copy sets had already drifted:
| Helper | Covers | Found in |
|---|---|---|
tool-filters | sidebar group boundary, odds scale, hit-rate window, side, bookmakers | every sidebar filter module |
tool-filter-bar | the bar's pills, and the league pill whole | 20 pills, 7 of them leagues |
tool-pagination | the pager | 15 files, 6 with the page strip |
tool-table-columns | crest and numeric cells, header + meta | every DataTable |
The drift is the point. One screener's page strip stepped back one page and the
other's stepped back two. TeamCrest existed twice, byte for byte apart from a
single ? in its prop type. Every league pill re-derived the same rule — a
pill shows a crest only when exactly one league is picked, because two crests
is a worse label than a count — and derived its summary string separately from
the value it described, which is how a pill ends up saying "All leagues" while
the panel shows three selected.
The bar and the panel are different helpers on purpose. tool-filter-bar is
the pills; tool-filters and AdvancedFilters are the panel underneath.
One-off meaning still uses the helper boundary
A control unique to one board stays composed at its call site, inside a
ToolFilter. Do not grow a named domain helper by adding one prop per caller,
and do not bypass the boundary with raw filter-group primitives.
A feature renders. A composed owns.
components/features/ (web) and blocks/ (native) hold UI only. No
useSWR, no fetch, no useQueryState, no derivation of one value from
another. A feature takes props and returns markup. If you can't test it by
passing it an object, it is in the wrong folder.
Everything else lives in composed/, one directory per feature, named the same:
composed/team-screener/team-screener.tsx ← state, queries, derivations
components/features/team-screener.tsx ← props in, markup out
app/…/team-screener/page.tsx ← imports the composedThe route imports the composed, never the feature. That is what keeps the
rule enforceable: a feature with a useSWR in it has nowhere to get its data
from, because nothing above it is passing any.
Native already works this way — composed/referees/referees.tsx runs the query
and hands rows to blocks/referees/referees-block.tsx — so this is the web app
catching up to a shape the product already has, not a new invention.
Why bother. These files reached 5,000 lines by growing a fetch next to the markup that needed it, forty times. Splitting them is not tidiness:
- A feature you can hand props to can be rendered in Storybook, in a test, or in a second context, without a network.
- The logic becomes readable on its own.
team-screener's data flow was 300 lines interleaved with JSX; alone in a composed it is 300 lines you can actually follow. - Two tools that fetch the same thing become visibly the same fetch.
Shared state types go in the composed, not the feature — TeamFiltersState
and its DEFAULT_FILTERS describe what the tool is steered by, and a filter
module importing them from a UI file is what made the old boundary meaningless.
The split is by dependency, not by line count
A useMemo that maps rows to display strings is UI work and stays. A useMemo
that decides which endpoint to call is not. The question is never "is this
logic" — it is "does this reach outside the component for anything".
A page that is not a tool is not a feature either
The rule above is about tools. Some pages are not: the odds converter and the
stats explainers are there to be found by search, and the Lineup Hunter and
Odds API pages are there to sell something. They have no board, no filters and
no composed to own them, so features/ — where every neighbour is one screen
of the product — is the wrong shelf.
They get their own two, named after what they are for:
components/seo/ odds-converter, player-stats, team-stats
components/marketing/ lineup-bot, odds-api
app/(seo)/… both groups render PublicRootLayout, not the app shell
app/(marketing)/…The route groups already split this way, and both render
PublicRootLayout: no sidebar, no filter panel. The folders now agree with
them, which is what makes "is this a tool?" answerable by looking rather than
by reading the file.
A non-tool stays out of the tool catalogue. statshub-config gives an
entry a category only when it belongs in a group in the sidebar. Lineup Hunter
keeps an entry without one — its title and summary are read by metadata and by
the bird's-eye view — and that absent category is the
whole reason it never shows up in the sidebar, search or a breadcrumb.
On web
apps/statshub-web/src/components/layout/app-sidebar.tsx builds all of it. AppShell is a
SidebarProvider wrapping AppSidebar and AppWorkspace.
| Region | Rendered as |
|---|---|
| Nav sidebar | AppSidebar, --sidebar-width: 13.5rem, defaultOpen={false} |
| Filter panel, desktop | A sticky <aside>, w-72, with Saved views fixed above its scrolling filters |
| Filter panel, mobile | A left-side <Sheet> — see overlays |
| Top controls | sticky top-16; one primary row followed by zero or more independent rows. All filters opens the mobile panel |
| Content | SidebarInset → AppHeader → top controls → one shared inset → children |
All of the filter chrome renders only when findTool(pathname) matches. A page
that is not a tool gets the nav sidebar and the content, and nothing else.
Content insets belong to the workspace
AppWorkspace gives every tool the same content edge: px-4 on compact
screens, sm:px-6 from the small breakpoint upward, pt-6, and pb-8.
Feature views do not repeat those outer padding utilities. Their root may set a
width constraint such as mx-auto max-w-6xl, but it must inherit its inline and
top inset from the workspace.
That inline edge is shared, not the content region's alone. The header holding
the breadcrumbs and every FilterBarRow use the same px-4 sm:px-6, so the
breadcrumb, a row of filter pills, a league rail and the board beneath them all
begin on one vertical line. A bar at a tighter px-4 while the board sat at
sm:px-6 read as a misalignment rather than as a deliberate inset, because
that is what it was.
This keeps a wide table, an empty state and a narrow calculator aligned even
when the control stack above them has a different number of rows. Padding
inside a card or table toolbar still belongs to that component; it is not a
page inset. If content genuinely needs to bleed through the shared gutter, make
that exception explicit at the content boundary with -mx-4 sm:-mx-6.
Every tool starts with a visible PageIntro. It composes the shared Alert
with a centered h1 and description; the breadcrumb does not replace it. Keep
the alert at the inherited workspace edge, before status and result content.
Filters arrive by portal, not by the slot they look like
app/app has @filters and @importantFilters parallel-route slots, and both
render null. They exist to reserve the region; the filters themselves are
portalled in from the feature view:
<ImportantOnlyToolFilters>
<PrimaryDimension />
</ImportantOnlyToolFilters>
<ToolFilterRows>
<FilterBarRow>
<SearchInput />
</FilterBarRow>
<FilterBarRow>
<LeagueRail />
</FilterBarRow>
</ToolFilterRows>
<FullToolFilters>
<LeagueSelect />
<BookmakerToggles />
</FullToolFilters>FullToolFilters portals into #app-full-tool-filters in the panel,
ImportantOnlyToolFilters into the primary top row, and ToolFilterRows into
the row stack below it.
Two positions on the primary row are fixed for every board, so the controls a reader reaches for first do not move between tools:
| Portal | Lands | Holds |
|---|---|---|
ToolSearch | Immediately after the "Important filters" label | The tool's text search |
ToolBarActions | Immediately left of Clear | Column visibility and the like |
A board does not put its search on a row of its own, in a toolbar above the
table, or inside the content. ToolSearch is bounded rather than greedy — the
pills beside it are why the row exists.
Declare each rendering explicitly
Use ImportantOnlyToolFilters when the compact control has a fuller equivalent
inside FullToolFilters. Use ImportantToolFilters only when the exact same
control is complete enough for both locations. ToolFilterRows is top-only.
This is why a view declares its filters where the filters logically live — beside the state they drive — rather than in a route file far from it.
The panel's open state is in the URL
const filterPanelParser = parseAsStringLiteral(["open", "closed"] as const)
.withDefault("closed")
.withOptions({ history: "replace", shallow: true }); history: "replace" matters: opening a panel is not a navigation, so it must
not add a back-button step. shallow: true keeps it off the server. See
where state lives.
New tool screens go in app/app, not (legacy)
21 of the 23 views using these portals are still under (legacy). The portal
itself lives in app/app, and (features) is where the rebuilt screens go.
A divider runs wall to wall, and the gutter goes inside it
A rule is the edge of the section it heads. An edge that stops short of the content it spans does not read as a boundary — it reads as an underline on a label, and the eye takes the section to be narrower than it is.
So a row that draws a rule breaks out of its container's horizontal padding and puts the same padding back inside itself. The border reaches both walls; the first word, the first cell and every heading below it still line up.
<div className="full-bleed border-b border-border">…</div>full-bleed is one utility in globals.css and it is the whole move — negative
margin out, matching padding back in, and the explicit width a percentage needs
so the negative margin stretches the box instead of sliding it sideways.
How far to bleed is the container's answer, never the row's. A container that
pads its children declares --gutter with the value it pads by:
<div className="px-4 sm:px-6 [--gutter:1rem] sm:[--gutter:1.5rem]">The board workspace already does, so anything inside a tool page can bleed without knowing where it is. A card that pads more tightly sets its own, and the row inside it stops at the card's edge instead of hanging over both sides. A container that declares nothing gets a row that stops where it always did — the fallback is zero, because a guess at someone else's padding is how a rule ends up 12px past the edge it was meant to meet.
A panel with its own border is already the edge
This is for a rule inside a padded region. A bordered, rounded container — a
card, DataTable's scroll box — IS the boundary, and its dividers run to that
border and stop. Bleeding one of those past its own outline is not full bleed,
it is a broken card.
ContentTabsList is the reference implementation: the rule under a board's tabs
is the top edge of everything below it, and it has run wall to wall since before
the utility existed. The lineup card's header and footer rules, the filter bar
and the app header all draw the same line for the same reason.
It is also the only in-page tab treatment. Its compact type and icon sizes are
fixed rather than caller-selectable. Labels are plain text: no counts, status
badges, promotional New markers or responsive type overrides. Put a result
count beside the result set, not inside the control that selects it.
Segmented controls: filled sets, underlined navigates
Two variants, and the choice is not cosmetic.
| Variant | Means | Use for |
|---|---|---|
default — a filled track | "this is the setting" | A filter with two or three values |
line — a rule with the active tab underlined | "this is where you are" | Sibling routes: Player/Team, All props/Watchlist |
Rendering route tabs filled made a page switcher look like a filter sitting
among the filters, which is why ScopeSwitch — the route-backed one — is
always variant="line", and always full width. A rule that stops under the
last word reads as an underlined label; one that spans the row reads as the
edge of a section.
The underline travels
A line list draws one rule for the whole row and slides it from the tab you
left to the tab you picked. It comes with the variant — TabsList renders the
indicator itself, and it holds still under prefers-reduced-motion.
Two things follow from there being exactly one of them:
- A row of buttons that each draw their own underline is a bug. The home board's date rail was seven of those, so the rule blinked out under Today and back in under Wed rather than stepping across the week. If a set of things reads as tabs, build it as a tab list; the travel and the arrow-key navigation come with it.
- Do not bold the active tab in a rail of uneven labels. "Wed 2 Sept" going semibold reflows every tab to its right while the rule is still moving. Mark it with colour instead, as the date rail does.
The template owns the scope row
Pass the options, not a rendered control:
<FeatureTemplate
scope={[
{ label: "Player", href: "/prop-screener/all" },
{ label: "Team", href: "/prop-screener/team" },
]}
content={<ResultsTable />}
/>Every board used to wrap its own ScopeSwitch in the same
SegmentedToolFilters + FilterBarRow pair, so five copies of the wrapper had
to agree about where the row sits and which variant it uses. The template owns
both now, and a board with scopes cannot put them anywhere else.
FeatureTemplate renders those options through ScopeSwitch, which in turn
uses ContentTabsList. Template-owned scopes and local section switches
therefore share the same compact row; neither exposes a large size or a badge
slot.
On mobile
There is no nav sidebar and no persistent panel — a phone has room for the board
or for the filters, not both. The regions map onto three pieces of
@statshub/ui-native:
| Web region | Native equivalent |
|---|---|
| Important filter bar | filters/filter-bar.tsx — a scrolling row of chips |
| Filter panel | filters/filter-panel.tsx — grouped rows, inside a sheet |
| The sheet around it | sheet/filter-sheet-screen.tsx — titled bar, body, pinned footer |
FilterSheetScreen is a nested Expo Router stack, and which routes are in it
is the board's business — the kit owns the chrome, not the navigation.
Four rules the chip row encodes, worth knowing before you build one:
- A chip is a control, not a
Badge. It is pressable, it has a selected state, and it usually opens something. Badges have none of those. The kit keeps them apart so nobody reaches for the label when they want the button. - Selected is a fill, not a tick. A tick has to be found and read; a filled chip is seen. Scanning a filter bar, the reader's real question is "what is currently on", and the answer has to survive a glance.
- The count lives in the chip —
Leagues · 3, not a separate badge. A chip that says only "Leagues" makes the reader open it to find out. onClearis part of the bar. A board narrowed to nothing with no visible way back is the commonest dead end in a filtered interface.
Advanced filters that fit one screenful use FilterPanel directly. The
screener's tray predates it and keeps its own copy, coupled to a seven-view
stack — do not extend the panel to cover that case.

