---
title: Rework the Matchup History Table Presentation
type: task
status: completed
description: >-
  The head-to-head meetings table needs its Proj and Max column pairs attributed to a team, and its
  accordion disclosure reconsidered in favour of a layout that fits a large display and rearranges
  for small screens.
base_uri: user:task/league/rework-matchup-history-table-presentation.md
created_at: '2026-09-11T02:27:25.591Z'
entity_id: d1ed1866-ddf3-469c-8357-edc1d7bb1075
finished_at: '2026-09-11T03:08:48.018Z'
owner_identity_uri: █████████████████████████
priority: Medium
public_read: true
tags:
  - user:tag/league-xo-football.md
updated_at: '2026-09-11T03:08:48.018Z'
visibility_analyzed_at: '2026-11-05T23:50:16.475Z'
---

The head-to-head section on the matchup page works and is legible, but its presentation was built column-by-column rather than designed. Two things are wrong and one is an open question.

## What is wrong

**The `Proj` and `Max` column pairs do not say which team they belong to.** Each renders twice — team A then team B — under identical headers, so a reader has to count columns back to the two team cells to learn whose projection they are reading. The `Pts` pair gets away with it because the two team cells bracket it directly; `Proj` and `Max` sit past the margin column with nothing between them and their subject. This is the defect that prompted the rework.

**The accordion may not be earning its place.** The meetings table is behind `@components/accordion` with an `All N meetings` summary, on the theory that the series summary reads at a glance and the table is second-tier. On a wide display there is room for both, and the expand control is then a click between the reader and the thing they came for.

## The open question

Whether `Max` is the right header. It renders `potential_points` — what the team would have scored that week with its best legal lineup from the players it rostered, computed by `optimizeStandingsLineup` in `libs-shared/calculate-standings.mjs` over `active[week][tid]` (bench included). `Opt` or `Best` may say it more directly. `Proj` and `Pts` are unambiguous.

## What is already done

Two commits on master, both verified in a browser at seven viewport widths:

- `89dd2550d` — percentile paint via `PercentileMetric` against a distribution computed from the series itself; week column ahead of season; one-decimal points, whole-number projections, right-aligned numerals; a fixed-width grid the row centres so a wide desktop's slack lands in the outside margins rather than between the two scores; both team-crest defects fixed (the `team=` prop clobbered by connect, and `.team__image` unsized and collapsing to 0x0).
- `aca5b5fc7` — the winner marked by weight plus an inset ink rule on its outboard edge, replacing accent-red text.

## Constraints the rework inherits

- **Hue is spoken for.** Percentile tiers paint the cell ground; team names carry each franchise's own colour; the one accent is reserved for hover, focus and error. A new mark has to be typographic, structural, or positional.
- **Recession by value is unavailable.** A fixed grey fails contrast over the darker tiers — measured at 2.66:1 on `poor`. Any "this is secondary" treatment has to survive a ground that varies per cell.
- **The grid no longer flexes**, which is what makes the centring work and what makes every width a measurement rather than a convention. The current column set sums to 662px above 700px and 298px below it. A layout change means re-measuring, not re-guessing.

## Files

- `app/views/components/matchup-history/matchup-history.js` and `.styl` — the component
- `app/views/pages/matchup/matchup.js:205` — where it mounts, inside a `matchup__section`
- `libs-shared/calculate-matchup-series.mjs` — the series shape; `app/core/selectors.js:948` resolves the per-season team records
- `app/views/components/percentile-metric/` — the tier ladder and its paints
- [[user:text/league/matchup-page-architecture.md]] — the page's architecture; line 66 names the accordion as the page's disclosure primitive and would need a touch if this section stops using one
- `STYLE.md` — the design system, including the traps that govern any style verification here

## Plan

- [x] Decide how a derived column pair states its team. **Positional, by mirroring the row about the two scores.** Each team's figures sit on that team's own side — name, potential, projection, points, reading inward to the seam where the two results meet — so every column's owner is the nearest team cell, which is the mechanism `Pts` already got away with, extended to cover all six derived cells. The header's two team cells, which were empty, now carry each franchise's abbreviation, so the pivot the mirror turns on is labelled rather than inferred. The header says who a franchise is now and each row says what it was called then, which is why both exist.
- [x] Settle the `Max` header. **`Potential`, spelled out, and `Proj` follows it to `Projected`.** `Max` named an extremum without saying of what, and this section prints a real one two cards below in `MILL high` / `FSS high` — so the header read as the team's best game of the series rather than as the points that week's roster would have scored in its best legal lineup. `Opt` and `Best` inherit half that problem each. `Potential` is the app's own term, carried by the stats page's column group and its tooltip. `Wk` became `Week` and `Pts` became `Points` for consistency.
- [x] Decide whether the accordion stays. **Gone.** The page hands this section 712px at a 768px viewport and 1144px at 1600px against a row that measures 678, so there was room for the whole series at every width the disclosure was protecting. The summary cards, the meetings table and the extremes are now one always-visible composition. Vertical cost measured: 666px at 390px, 560px at 768px and above, for an eleven-meeting series at the bottom of the page with nothing below it.
- [x] Design the wide arrangement first, then the small-screen rearrangement as its own layout. The margin column was dropped to pay for the full-word headers: it is `points_a - points_b` printed beside both operands, it belonged to neither team so it broke the mirror, and the section already carries the average, largest and closest margin as cards. Narrow keeps week, season, both teams and both scores, and **rearranges rather than sheds** — the crest goes and the abbreviation stays, which is the opposite of the usual trade and is the right way round here because `teams.image` is nullable and `TeamImage` renders an empty box for a team without one, so a crest-only cell is blank for exactly the franchises whose identity most needs stating.
- [x] Re-measure the grid. Wide sums to 678px, narrow to 322px. Checked at 360, 390, 600, 700, 768, 1024 and 1600: no horizontal overflow, no clipped name, no head cell over its neighbour, leading and trailing gaps equal at every width. Driven with a five-character abbreviation — the schema maximum, and exact rather than a worst case because `.team__name` is monospace — rather than the three and four this league renders.
- [x] Run every style check against a control that proves it can fail. A 200px points column took overflow from 0 to 282; `justify-content: flex-start` took the gaps from 245/245 to 0/490; a 16px head font took `Potential`'s slack from -4 to +28; an unsized `.team__image` took the crest to 0x0; the truncation sweep fired on its own against a 48px narrow team cell. Two live defects came out of it — `Week` overflowed its 26px narrow head cell by 6px, and a five-character abbreviation clipped in that 48px cell — both fixed by measurement.
- [x] Update [[user:text/league/matchup-page-architecture.md]]. The page now has no progressive disclosure at all, and the doc says so, with the width budget that retired it and the note that `matchup-history` alone breaks the page's 600px breakpoint convention at 700px.

## Delivered

- `89dd2550d` — percentile paint, a centred fixed grid, and the missing crests
- `aca5b5fc7` — the winner marked by weight and an inset rule, not accent red
- `b2b8907a2` — the mirrored row, the named columns, and the accordion removed
- `72fd663e7` — one measure for the section, paint only the scores

## The polish pass

Viewed at full-page scale in a browser rather than as a cropped section, the composition was still wrong for a reason no amount of column work could reach: **the section had three right edges and a rule over the top of them.** At 1600px the masthead ran the container's full 1144px, the summary cards stopped at 833, the table at 678 and the extremes cards at 664 — and `.table__container` paints white, so centring the row inside it rendered 490px of empty slab with the table marooned in the middle. Every block was internally correct and nothing lined up.

The fixes, all measured:

- **One measure for the whole section**, the table's own width. Above the breakpoint the masthead, both card rows and the table share both edges exactly. The container sizes to its content, which is what stops the narrow table painting white past its last column below the breakpoint. One band is knowingly ragged and recorded in the file: at 600-715px the 322px row sits in a 568-677px section, because capping the section to the narrow row floors `.metric-card` at 100px and wraps five cards into three rows.
- **Paint drops to the two score columns** — 22 cells rather than 66. Six tinted cells in a ten-cell row left the one quantity the section exists to carry no louder than two estimates beside it. The derived figures recede on size and on a grey instead, and that channel only opened by taking the paint off: over the tier tints no fixed grey could pass, but on the row's own ground one does, at 5.17:1 measured against the composited ground.
- **The breakpoint was 9px too low.** The wide row needs 678px and the page hands a section `viewport - 32`, so 701-709 rendered the full table into a container that could not hold it. Now 715.
- **`matchup-history__row--current` could never render.** The selector passes the viewed matchup as `exclude_matchup_id`, so it is absent from its own series — confirmed in the DOM as 11 rows and 0 current. The class, the prop and the `matchup` prop it needed are gone.
- **`.team__image` ships a three-layer material shadow** — which STYLE.md spends on the floating action alone — **and paints white with a radius when a franchise has no `teams.image`**, so rows showed a blank shadowed disc asserting a crest that does not exist. Shadow off, empty box unpainted, space kept so the names hold one edge.

One verification check was rebuilt because it could not fail: slack measured against `row.scrollWidth` reads 0 at every setting, since `.table__row` carries `min-width: 100%` and stretches to whatever container it is given. It measures the summed cell widths now, and the control moves it from 0 to 222.

## Left standing

Three unresolved stylus tokens sit in the served CSS on this page — `$rt_space_xl`, `$rt_space_lg`, `$rt_text_muted` on `.table-empty-state`, from `repository/active/react-table`, whose stylesheets are compiled by the app's own stylus-loader and never see the library's `tokens.styl`. Pre-existing, outside this component, and already written down at the top of `app/views/components/data-view-empty-state/data-view-empty-state.styl`, which owns its padding locally for exactly that reason. Named here only so the sweep's non-zero reading is not re-investigated as this lane's.
