Design system

Specimens

Every token, type step, motif, data pattern and state in the system, in the theme you are looking at. Every ratio on this page was measured by the page, from the computed value of the token beside it, at the moment you loaded it — nothing here is quoted. Flip the theme and every figure re-measures.

Ground and ink

Three grounds. --pitch is the same colour in both themes, which is the point of it: the dark data panel does not change when the page does. A token's ratio is quoted against the worst ground it can legally sit on.

--paperpage ground
--surfacecards, panels
--pitchdark panel · both themes
--inkprimary text
--ink-bodybody copy
--text-helpevery caption, label, tick
--hairline1px dividers · decorative

--text-help is the most consequential token in the system, because it carries most of the small text in the product. The tempting value is a 45% ink; it measures 2.98:1 and fails AA outright. Where you want quieter than body, use it at a smaller size — never at a lower alpha.

Identity — who

Two hues, a near-black, a dashed rule and a hatch — not a colour per entity. Seven mutually distinguishable hues do not exist in a palette this restrained, and faking it produces a chart that lies to about one reader in twelve. Many entities on one chart are separated by naming, never by adding a third hue.

--who-methe manager reading
--who-enginethe engine's team
--who-rivalother managers — drawn, labelled, not hued
Field average
--who-fielda baseline, never a series — always text-labelled
--who-elitea benchmark cohort — a pattern, not a hue
Measured separation · ΔE76, worst of normal vision and three dichromacies · floor 25
me vs engine
me vs rival
engine vs rival

The second hue is violet, and the obvious choice is wrong. A dark blue against this green measures in the 12–21 band — under the floor — because under protanopia and deuteranopia a desaturated dark green and a dark blue both collapse toward the same blue-grey. And the rival neutral is nearly black on purpose: at a mid-grey it measures around 18–22 against the brand green, because a mid-grey and a mid-green share a lightness, and separation has to come from lightness once hue is spent.

Valence — good, bad, level

Position carries valence; hue only reinforces it. This is forced, not preferred. The best red/green pair available measures 18.3 light and 23.8 dark, both under the floor, because every red/green pair collapses under deuteranopia. So a signed number always shows its sign, and a bar always grows from a visible zero line.

--valence-goodgains, positive margins
--valence-badlosses, negative margins
--valence-flatexactly zero — a result, not an absence
Signed, from a visible zero — the sign and the position both carry it
Salah+4.2
Haaland−2.7
Saka0.0

State — which kind of absence

Never a valence, never an identity. Three kinds of nothing plus one demand. A blank is drawn as a blank, never a zero — this is the product's honesty made visible, and the three patterns are at three different angles so they are told apart from each other rather than merely from a flat fill.

Pending

Recorded, not yet settled. Normal.

Missing

Not recorded. Unrecoverable. 135°.

Unreadable

The source would not serve it. 90° — a different failure, and the reader is owed the difference.

Elite cohort

Identity, not state — 45°, and the reason the two above are not.

Blocked
Deadline in 2 hours and no advice recorded. Action required now — full width, because a demand that fits in a cell reads as a status.

Accent, and where it may not go

Captain · verdict

On --pitch, which is the only ground it may appear on.

The same colour on light paper

It may not appear on a light ground at any size, for any reason. Not as a 10px label, not as a 1px rule. The number is not a close call — it is a quarter of the floor.

Measured against the literal light paper, not against --paper: that token is near-black in the dark theme, where the same accent reads 9.48:1 and the rule does not bite. The constraint is on light grounds, not on a token that holds one half the time.

Interaction colour is not meaning colour

--accent-ui is chrome — focus rings, the active tab, a pressed state, a slider. It resolves to the same hue as --who-me today and it is still not the same token, because collapsing them is exactly how one token ends up meaning "selected", "recommended", "focused" and "captain" at once.

The fixture ramp

Seven steps, the only diverging scale in the product, used only for fixture difficulty and never borrowed. A fixture's kindness is a magnitude with a sign, not an identity — which is why it must never reuse an identity slot. Each step ships a paired ink; lightness is monotonic outward from the midpoint in both arms and both themes, which is what keeps it readable when the hue is not.

−3
−2
−1
0
+1
+2
+3
TeamGW6GW7GW8GW9GW10
Arsenal BUR (H) nEW (A) SHU (H) mci (A) BHA (H)
Spurs LUT (H) liv (A) FUL (H) bur (A) CHE (H)

Type

Display in Archivo Expanded, body in Archivo, and every label, stat, verdict, timestamp and button in JetBrains Mono — which is the single decision that makes the whole thing read as an instrument rather than a blog. Archivo and Archivo Expanded are one file: the same variable font at font-stretch: 125%.

Display · 64px · −.04em
public site only
Nine points
Display · 32px · −.03em
app ceiling — once per screen
Nine points clear
Display · 26px · −.02em Nine points clear of the field
Display · 20px · −.01em Nine points clear of the field average

Two display ceilings, deliberately. The public site goes to 64px, where a page has room and one headline is the whole screen. Inside the app the ceiling is 32px, used once per screen — a 38px name is unusable at fifteen rows and eight columns, which is what the app actually renders.

Body · 17px · public The engine ranks a squad on measurable signals and shows its working.
Body · 15px · app The engine ranks a squad on measurable signals and shows its working.
Body · 14px · dense The engine ranks a squad on measurable signals and shows its working.
Mono · 13px · .1em Expected points · next six
Mono · 11px · .12em Deadline · Fri 18:30
Mono · 10px · .16em
table headers
Rank · Points · Δ
Tabular numerals — non-negotiable
✓ tabular — digits stack
  1,118,402
     91,077
        743
         11
✗ proportional — they do not
  1,118,402
     91,077
        743
         11

Jargon is never the label. "Expected points over the next six gameweeks", not pred_horizon. The short form can live in a tooltip; it may not be the thing on screen.

Space and density

Spacing is a scale and a token, never a hand-written margin. A stylesheet with eleven distinct margin values has no system. Four steps carry the app, four more carry the public site, and layout does the spacing — flex or grid with gap, never per-element margins that collapse or double.

--space-1 · 8px
--space-2 · 12px
--space-3 · 16px
--space-4 · 24px
--space-5 · 32px · public
--space-6 · 48px · public
--space-7 · 72px · public
--space-8 · 96px · public

Every app screen has a budget: one viewport-height in its default state, on desktop and at 375px. Everything beyond goes behind a segment, a <details> or a counted disclosure. The measured defect today is length — the main decision screen runs 7.9 viewport-heights on a phone for a job that is one transfer and one captain pick.

Geometry and motif

Square corners everywhere. No border radius, no shadows — depth comes from ground changes and hairlines. The football is in the materials, never in the illustration: no player photography, no crowd shots, no stadium hero, no whistle icons, no cartoon boots. At most one texture per screen, and never behind a table or a chart.

Hexagon · the identity mark

Pointy-top, height = width × 1.1547 or it stops being regular. Never put a border on a clipped hexagonclip-path cuts it on every diagonal and it renders as two vertical bars.

Grass-blade full stop · the signature
Nine

Three tapered blades and a hexagon replacing a period — large, tiny, medium, left to right, hexagon overlapping their roots. Once per viewport at most, and only at 26px and above; below that it is noise.

Honeycomb lattice · ~5.5% stroke

From a ball's panels. For light sections that are mostly type, and never behind data. You should notice it on the second look, not the first — if it competes with this sentence it is too strong.

Goal netting · the dark panel only

Two out-of-phase layers, squares kept square and orthogonal. A 45° version reads as a fence, not a net. The sway runs at 16s and 22s — the only continuous animation in the product, long enough that it is never noticed directly, and it stops dead under prefers-reduced-motion.

Touchline · a section rule where a hairline is too quiet


A 3px unevenly-stopped rule, shown here on both grounds. The uneven alpha is the point — worn paint, not a border. A clean 3px line is just a thick border and reads as a mistake.

§5 describes it as a white gradient, which is right on the dark panel and invisible on paper — the public site's footer rule vanished completely. So the token carries only the wear and the element inks it through a mask from currentColor. Same worn paint, either ground.

Mown grass band

The full-bleed break on the public site, and the answer to "where does the hero photograph go" — nowhere. A dark ground, repeating-gradient stripes and an inset touchline rectangle. Convincing at any width, costs a few lines, and has no licence attached to it.

Data display

Mono headers at 10px. Rows 32px dense. Hairline between rows, none within. No zebra striping — it is noise at this density and the hairline already does the work. Numerals tabular and right-aligned, text left. A sortable header says so.

Player Next 6 Price Owned Last
M. Salah34.1£12.948.2%13
E. Haaland31.8£14.161.7%9
B. Saka~27.4£10.034.9%6
C. Palmer~24.9£10.729.1%
The provisional vocabulary — four words, no exceptions
13 confirmed

FPL stated it, it cannot change. Plain.

9 provisional

FPL stated it, it can still change. Dotted underline, muted.

~27.4 projected

This app computed it. Always ~-prefixed, and never at the same weight as a confirmed one.

not obtainable

Em dash. Never 0.

Rank over the season · a log axis, because a linear one spends 91% of its height where nothing happens
1K 10K 100K 1M FIELD YOU · 21,400 ENGINE · 34,900 + 5 OTHERS GW7 GW1GW11

One y-axis, ever — two measures of different scale are two charts on a shared x. Drawn at measured pixel width so type never scales with the container. Paths break at missing values so a gap is a gap, never an interpolation. One crosshair reading out every series, not per-point tooltips. Reference lines are dashed and always carry a text label.

Matrices are glyph-primary and colour-free
ManagerSalahHaalandSakaPalmerIsak
You·
Engine·
Fieldwork FC·

present · secondary · marked · · absent. It spends nothing from the hue budget and survives every dichromacy absolutely, which a five-by-five grid of tinted cells does not.

Sparklines · 60×18, one hue, endpoint marked
PlayerFormBestWorstMean
M. Salah 1326.4
E. Haaland 1615.1

Prefer three columns of best / worst / mean to the sparkline where the exact numbers matter. The sparkline shows a shape; the columns answer the question.

Every derived figure opens, and its parts must visibly sum to it
Expected points over the next six gameweeks 34.1
Minutes expectation+18.4
Attacking returns+12.7
Fixture adjustment+4.1
Rotation risk−1.1
Total34.1

A breakdown that approximately sums is worse than none. This is the product's core promise made literal, and it is also the best onboarding available — nobody reads a methodology page, but everyone opens a row. A row that expands on click must expand on Enter, which <details> gives for free and a div with an onClick does not.

Progressive disclosure carries a count
6 others considered

A section labelled 6 others is one the reader can skip without opening; a section labelled 0 needs no thought at all.

Motion

Almost none, and all of it ambient. The netting sway above is the only continuous animation. Buttons darken on hover by roughly calc(l − .06); secondary fills to ink-6. No lift, no shadow, no transform. Data never animates on load — a chart that draws itself is a chart you cannot read for 400ms, and this product is read in a hurry on a Friday evening.

prefers-reduced-motion: reduce is honoured by pausing the netting and removing every transition — not by shortening them. A setting that leaves a 16s loop running has been acknowledged, not honoured.

Accessibility, as a floor

AA everywhere: 4.5:1 for text, 3:1 for graphical elements and UI boundaries. Nothing carries meaning in colour alone — every state also has a word, a glyph or a position, which is why the three absences above are three angles of hatch and not three tints. Everything interactive is keyboard-operable with a visible focus ring (tab through this page), and a row that expands on click expands on Enter.

Both themes are designed, not inverted. The full light palette is on bare :root; only what changes is redefined under prefers-color-scheme: dark and again under [data-theme], so the control at the top of this page beats the OS in both directions. Verify with uv run python scripts/check_palette.py, which re-measures every figure in tokens.css and fails if a comment and a value have drifted apart.