ConventionsUI

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 itLeave it
Hit rate, over-percentage, conversion — anything where higher is betterOdds, lines, prices
An edge, or any claim about valueCounts: appearances, games played, shots
A league position, a rank, an opponent's difficultyDates, times, ids, shirt numbers
A card or foul average, where the scale says which end is badMinutes, 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";
ScaleShapeFor
hitRateTone(value)text colour, 8 bandsa percentage where higher is better
boostTone(value)text colour, 4 bandsa super-sub boost, as a percentage uplift
edgeTone(value)badge, 3 bandsan expected-value edge
refereeStatTone(kind, value)text colour, per kindcards, fouls, penalties and their over-percentages
leagueZoneTone(position, teams)badgea league position, by zone
opponentRankTone(rank, total)badgefixture difficulty, by thirds
positionTone(position)badgea playing position — categorical, not a ramp
marketTone(market)badgea 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.

On this page