Icons and imagery
Every filter option and every row of data carries a crest, a headshot or a symbol — and what to do for the ones that cannot.
A filter option or a data row that is only text makes the reader parse a name before they can act on it. "Bundesliga", "2. Bundesliga" and "Bundesliga 3" are three seconds of reading; three crests are one glance. This matters most on the surfaces that are lists of near-identical strings — a league rail, a bookmaker allow-list, a table of players — which is most of the product.
So: carry a visual on every filter option and every data row, and fall back through a chain rather than to nothing.
The fallback chain
FilterOptionVisual in apps/statshub-web/src/components/reusable/filter-controls.tsx
implements it, and every set-valued filter control goes through it. Four steps,
in order:
| Step | Renders | When |
|---|---|---|
| 1 | option.imageUrl in an Avatar | The entity has a real picture — a crest, a headshot, a bookmaker logo |
| 2 | The label's initials, as the AvatarFallback | The image 404s or has not loaded |
| 3 | option.symbol, an explicit LucideIcon | The option is a concept, and you know which symbol it is |
| 4 | filterSymbol(value, label, context) | Nothing was supplied — resolve one from the words |
Only when all four come back empty does the row render text alone.
export type FilterOption<T extends string | number = string> = {
value: T;
label: string;
/** Crest or headshot. Falls back to the label's initials. */
imageUrl?: string;
/** Overrides the semantic symbol resolved from the value and label. */
symbol?: LucideIcon;
badge?: React.ReactNode;
disabled?: boolean;
};Passing neither imageUrl nor symbol is the normal case, not a lapse — step 4
covers most options without the call site doing anything.
Where the pictures come from
StatsHub entity artwork is stored in the R2 bucket and addressed by id. Use one of these paths instead of inventing a substitute icon:
`${R2_STORAGE_URL}/team/${teamId}.png`;
`${R2_STORAGE_URL}/player/${playerId}.png`;
`${R2_STORAGE_URL}/unique-tournament/${uniqueTournamentId}.png`;R2_STORAGE_URL and getBookmakerLogo are exported from
apps/statshub-web/src/lib/constants. Bookmakers go through the helper because their
files are keyed by name rather than id.
Use uniqueTournamentId, not id
A league has both, and they differ. id is the seasonal row; the artwork is
keyed by the competition. Getting this wrong yields a broken image for every
league in the rail, which the initials fallback then quietly papers over — so it
looks like a styling bug rather than a wrong id.
When there is no picture, resolve a symbol
apps/statshub-web/src/config/symbols.ts maps the product's vocabulary onto Lucide
icons. filterSymbol takes any number of candidate strings and tries each
against three tables in turn:
METRIC_SYMBOLS— shots, tackles, corners, cards, and their basketball equivalents.CONCEPT_SYMBOLS— odds, hit rate, edge, bookmaker, lineup, venue.FILTER_HINTS— regex patterns over the label's words, for the long tail.
// Nothing passed: the symbol is resolved from the value and label.
<FilterToggleGroup id="stat-types" options={statTypes} … />
// A concept the tables do not know. Name it rather than leaving it bare.
{ value: "supersub", label: "Supersub hit rates", symbol: Armchair }Adding a term to the tables is better than passing symbol at one call site:
the tables are shared by the filter controls and the metric columns, so one
entry covers both.
Non-photographic glyphs use a token, not an avatar
A yellow card, a position abbreviation, a market's initial — these are not
pictures of an entity and should not sit in a round Avatar. Use
FilterItemToken from @statshub/ui-web/components/filter-panel: a fixed 28px
square, so a row carrying a card swatch and a row carrying GK keep their
labels on the same left edge.
What has no visual, and stays that way
Not everything earns one, and a wrong icon is worse than none — it asserts a category the reader then has to un-learn.
- Booleans. A switch is its own indicator; an icon beside it adds a second thing to read for one piece of state.
- Ranges and thresholds. "1.30 – 10.00" is already a picture of itself.
- Free text. The search field has a magnifier; its value has no symbol.
- Ordinal positions. A rank column is numbers, and numbering them twice helps nobody.
If filterSymbol returns nothing for a term that clearly has a symbol, add it
to the table. If it returns nothing because the term genuinely has none, that is
the correct answer — do not reach for a generic circle to fill the space.
Data rows follow the same rule
The convention is not filter-specific. A table row, a result card and a list
item name the same entities, so they carry the same crest or headshot, resolved
the same way. AvatarWithFallback and PlayerAvatar in
@statshub/ui-web exist for the rows that are not
filter options.
Two consequences worth stating:
altis empty on a decorative crest. The team's name is already in the row; a screen reader announcing "Arsenal, Arsenal" is worse than silence. Where the image is the only identifier — a crest with no label beside it — it takes a realalt.- Size comes from the row, not the image.
size-5in a filter row,size-6in a table cell. An image that sets its own dimensions makes the row height depend on whether the picture loaded.
UI
The four regions every tool screen is built from, what belongs in each, and the overlay vocabulary that differs between web and mobile.
Overlays
Dialog is the web default and does not exist in the native kit — mobile uses sheets, and how a screen is presented lives in a route table rather than in the screen.