Numbers in tables
A number that has a good end and a bad end takes its colour from lib/colors.ts. Never from a scale written at the call site.
A number a reader judges gets its colour from lib/colors.ts. A number they
only read stays the colour of the text around it. There is no third option, and
in particular there is no scale defined next to the table that uses it.
The reason is not tidiness. A scale is a promise that the same value means the
same thing everywhere, and that promise is what makes a coloured number worth
more than a plain one. It has been broken here twice: first when
getHitRateTextClass was copied into four files and the copies drifted, and
again in three data tables that each grew a local rateVariant. Two of those
disagreed — the screeners called anything under 60 "secondary", the outliers
board called anything from 50 up "secondary" — so the same 65% was two colours
on two screens one click apart.
Which numbers qualify
Ask whether the number has a direction a reader acts on.
| Colour it | Leave it |
|---|---|
| Hit rate, over-percentage, conversion — anything where higher is better | Odds, lines, prices |
| An edge, or any claim about value | Counts: appearances, games played, shots |
| A league position, a rank, an opponent's difficulty | Dates, times, ids, shirt numbers |
| A card or foul average, where the scale says which end is bad | Minutes, ages, heights |
Odds are the one that looks like it belongs on the left and does not. 1.44 is not better or worse than 3.10 — it is a different bet — and colouring it would be asserting a judgement the app has not made.
The scales
import { hitRateTone, boostTone, edgeTone, refereeStatTone } from "@/lib/colors";| Scale | Shape | For |
|---|---|---|
hitRateTone(value) | text colour, 8 bands | a percentage where higher is better |
boostTone(value) | text colour, 4 bands | a super-sub boost, as a percentage uplift |
edgeTone(value) | badge, 3 bands | an expected-value edge |
refereeStatTone(kind, value) | text colour, per kind | cards, fouls, penalties and their over-percentages |
leagueZoneTone(position, teams) | badge | a league position, by zone |
opponentRankTone(rank, total) | badge | fixture difficulty, by thirds |
positionTone(position) | badge | a playing position — categorical, not a ramp |
marketTone(market) | badge | a prop market — categorical, not a ramp |
Two of them run the opposite way on purpose. refereeStatTone's over-percentages
climb green because 70% of matches going over 3.5 cards is what a reader hunts
for, while its cards-per-game runs red at the high end. opponentRankTone is red
at the TOP, because it answers "how hard was this fixture" and numbers against
the best third are the ones to discount. Each names its own rungs rather than
sharing a comparator, which is what lets them disagree safely.
Text or badge is not a style choice
A scale that returns text colour is for a number the reader ranks on — a hit rate, a boost. The figure is the thing; a pill around it puts a border and a fill on one cell in a row of plain ones, and reads as a control rather than as a reading.
A scale that returns a badge is for a label the app is asserting — an edge, a league zone, a position, a market. Those are claims with a shape of their own, and the chrome is what says so.
boostTone was a badge and is not one any more, which is the example: the
column the board is sorted by was the only one wearing a pill, on a board where
every other number is bare.
Three rules that come with them
Missing is absent, not bad. null returns text-muted-foreground, never the
bottom of the ramp. A player with no rate has not failed to hit.
Colour only. Every scale returns colour classes and nothing else — the call
site owns weight and alignment. Three of the four old copies also returned
font-semibold, which landed on call sites already setting font-extrabold, and
whichever Tailwind emitted last won.
Numbers in a column are tabular-nums. Proportional digits make a column of
figures ragged, and the colour is harder to read across a row that does not line
up.
Do not write a scale at the call site
If a number needs colour and no scale fits it, add one to lib/colors.ts with a
comment saying which end is bad and why. A variant={value > 60 ? …} next to a
table is how the last two divergences started, and neither was noticed until
someone put the two screens side by side.
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.
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.

