Every control below is a CSS transcription of the real DS component it will be built from (DS Tabs with the sliding pill + connector flares, DS Select small, DS Bar, DS text Button rows, Paper elevations). Click a tab or a readout stat.
primary.main hands-dealt / betting.range unopened / betting.fold facing-an-action), so a card reads as "which family am I in" at a glance. Seat identity is a 5px dot on the column label, and the same dots mark vs-action rows and both matrix axes — colour never fills a bar and never tints text. This is a new convention (the repo has no position→colour mapping today), approved and shipping as POSITION_COLORS in gto-hud-meta.ts. Labels stay text.secondary: the palette fails AA as 10px text.Three independent passes over this mock against libs/ui (tabs + select ·
surfaces/typography/readout · data-viz + tables). Raw reports:
tabs ·
surfaces ·
data-viz.
| Was | Now (DS truth) |
|---|---|
Tab padding 0 16px | 6px 16px 9px — DS pt-[6px] pb-[9px], labels were 1.5px low |
| Tab list rebuilt on every click | Built once; only aria-selected/tabindex change, so the indicator actually slides (200ms) and focus survives |
| Both panel corners flashed round on switch | Asymmetric like Tabs.Panel: losing a flush edge is immediate, gaining one defers to the indicator's transitionend (400ms fallback) |
Select: hover only, min-w 260px, text glyph chevron | focus-visible + data-popup-open (1px primary-main border), w-auto min-w-56, truncating value, 16px ChevronDownIcon |
Field label as body4/cap, 13px help circle | input/label 12px/100%/.15px/400, gap-1 px-2, root gap-0.5, 12px help icon at action.active |
Hover fill states-hover on rows/cards | The Button's own colors.button.text.secondary.hover = rgba(224,224,224,.1), plus focus/active layers |
Selected row = invented /12 bg + /28 ring | gradients.primary.horizontal(.08,.01); cards use the bg-gradient-primary-vertical token (.08→.01 full height) |
Panels: 6px radius, nested same-elevation Papers, bg-action-hover as a resting surface | Sections are not Papers — Divider-separated blocks on the Tabs.Panel (elevation3); cards/boxes elevation4, seat chip elevation5, radius rounded-md=12 |
Hand-bolded body3/body2 values | subtitle3 / subtitle2; uppercase keys are body4+uppercase (143%), not body4/cap (120%) |
Matrix 56px/66px tracks, 4px cells, #111 ink, 2px ramp | 49px/59.5px (= 3.5rem/4.25rem at the app's 14px root), rounded-sm=6 cells, grey-900 ink, ramp rounded-sm + gap-1 |
rounded-xl treated as 12px | 24px in this preset (tailwind-preset.ts); DS Bar corrected |
| No-spot = bare stripes + invented border | NoSpotTrack: alpha(text.primary,.02) base + .05 stripes, no border |
| Column bars had a 2px floor | No floor — a real 0.0 draws nothing, so zero can never look like a small value |
| Seat colours used as 10px label text | Hue on bars/dots only; labels text.secondary (CO 3.1:1 and BTN 4.0:1 failed AA) |
| Disabled/focus states missing on tabs + arrows; no roles | aria-disabled gating, focus rings, role=tablist/tab/tabpanel, aria-controls/labelledby, roving tabindex, Arrow/Home/End |
Dead hand-rolled pill CSS, stale spec rows, var(--amber) undefined | Deleted / corrected (--range), spec's "three ToggleButtonGroups" replaced with the real Tabs.Root props |
ComposedChart has no bar mark (only Area/Line/ReferenceLine/CartesianGrid) and one colorHex per metric, so per-seat colour is impossible there. Build by-position-columns.tsx from divs.StatTile. Ramp stays custom via alpha().Chip is a 100px pill, wrong shape).scrollable renders them always (disabled at the boundary) with permanent 48px padding — the mock hides them when the row fits. Ship the DS behaviour.title= tooltips exist only because this is a static file. Use DS Tooltip / Bar's config[].label.POSITION_COLORS in gto-hud-meta.ts); bars keep the family colour; colour never fills a bar or tints text.ByPositionPanel, LollipopRow, AxisFooter and FamilySeriesCards are deleted. No density toggle.scrollable tab list for all 10 stats; vs-action + matrix side by side (stacking at md); cards capped at 240px; default stat is VPIP.Everything below is decided. No new design calls needed to build it.
gto-hud-page.tsx (the GtoHudPanel with MAIN_STATS + AxisFooter), and the MAIN_DOMAIN shared axis with it.components/gto-hud-readout.tsx — the top block (variant chosen above).components/stat-tabs.tsx — the 10-stat selector.VsMatrixPanel, VsActionPanel, NoSpotTrack, row-layout.ts, StatDefinitionToggle/Body.FamilySeriesCards + the sparkline path (the column cards replace them), and ByPositionPanel/LollipopRow if the columns fully replace the lollipop rows — decide before starting, don't leave both.elevation2 → Tabs.Panel paperLevel={3} (the pill's flares merge into it) → sections are not Papers: plain blocks on the panel separated by a DS Divider → cards / readout boxes elevation4 → seat chip elevation5.GtoHudPanel hard-codes !bg-paper-elevation3 (gto-hud-panel.tsx:30), so a nested elevation4 panel was unbuildable without a new prop. With sections on the tab-panel surface, GtoHudPanel is only needed where something really is a card, and the !bg-* override disappears.gtoHud.header.pageTitle stays "GTO stats" and the crumb carries "GTO HUD".| Mock element | Build with |
|---|---|
| Readout container | Paper elevation="3", rounded-md p-2.5, flex items-stretch gap-2 |
| Family box | div on bg-action-hover border border-divider rounded-sm + 2px absolute left edge in the family color |
| Readout stat row | Button color="secondary" variant="text" size="small" with data-tst="gto-hud-stat-{TYPE}". Hover fill is the Button's own colors.button.text.secondary.hover = rgba(224,224,224,.1) (NOT states-hover), and it also forces text→text-primary. Selected = the app's animatedGradientClassName() surface (gradients.primary.horizontal(.08,.01)) — no invented ring alpha. |
| Selected card tint | bg-gradient-primary-vertical (gradients.primary.vertical() = .08→.01 over the full height). Do not hand-write a to-transparent to-60% gradient. |
| Row separators / hairlines | DS Divider (1px colors.divider), not ad-hoc borders |
| Value typography | subtitle3 (12px/157%/.1px/600) for readout values, subtitle2 for the strip variant — do not bold a body* variant by hand. Uppercase stat keys are body4 + uppercase (143%), not the body4/cap variant (120%). |
| Help icon / field label | DS HelpIconTooltip (12px icon at action.active) inside Field.Root/Field.Label (input/label 12px/100%/.15px/400, gap-1, px-2, root gap-0.5); the label turns primary-main while the dropdown is open |
| Tooltips | DS Tooltip / Bar's config[].label. The mock uses native title= only because it is a static file — never ship that. |
| Seat pod | Paper elevation="4" + rounded-full chip (bg-paper-elevation2 border-paper-elevation6, ring-2 ring-primary-main/12) |
| Stat tabs | Three ToggleButtonGroup size="small" (one per denominator family) in a flex gap-2 overflow-x-auto row — separate groups keep the sliding indicator intact and encode the grouping without captions |
| Tab label + value | Typography variant="body3" + variant="body4" color="text.ghost" |
| by-position & matrix row | flex flex-wrap gap-gutter items-start; by-position flex-1 basis-[460px] min-w-0, matrix shrink-0 |
| Panel titles | existing GtoHudPanel (subtitle2 + optional GTO column header) |
| Use | Token |
|---|---|
| Page / panel surfaces | paper.elevation2 page, elevation3 panels + readout, elevation4 by-position panel & seat pod, elevation5 borders |
| Family colors | hands dealt primary.main · unopened pots betting.range · facing an action betting.fold |
| Selected states | primary.states.focus (bg) + primary.main @28% (ring); hover action.hover |
| Text | value text.primary, label text.secondary, tab value text.ghost, scale hints text.disabled |
| F / C / R | betting.fold / betting.call / betting.raise.default |
| Radius | xxs family edge · xsm rows/cells · sm panels/boxes · med readout · full chip + bars |
| Spacing | spacing.gutter between blocks, spacing.panel inside panels |
| Type | h5 page title · subtitle2 panel titles · body3 values/labels · body4/cap keys and captions |
ComposedChart imports only Area/Line/ReferenceLine/CartesianGrid — there is no recharts Bar anywhere in libs/ui. So the column card is a new small component (plain divs, no chart lib): components/by-position-columns.tsx. It replaces FamilySeriesCards, which gets deleted along with the sparkline path.colorHex, so per-seat colouring is impossible there — another reason the columns are divs.StatTile in a DS Table. The ramp stays custom (alpha(colors.primary.main, 0.12 + 0.68·v/max) via @gto/helpers/alpha).NoSpotTrack component (base fill alpha(text.primary,.02) + .05 stripes); do not re-declare the gradient.data/gto-hud-meta.ts:
POSITION_COLORS = { UTG: colors.colorDots.lightOrange, HJ: colors.colorDots.teal, CO: colors.colorDots.blue, BTN: colors.colorDots.indigo, SB: colors.colorDots.purple, BB: colors.colorDots.pink }.paper.elevation2. Labels stay text.secondary.primary.main / betting.range / betting.fold) stay on the readout boxes, so family and seat never fight for the same surface.min-w-[136px], rows 22px with a 1px divider between them, seat pod w-[76px], chip 36px.basis-24 · track flex-1 min-w-[90px] · value w-10 text-right, row min-h-[38px] p-2.3.5rem label track + 4.25rem per aggressor — 49px / 59.5px at the app's 14px root font-size — gap-0.25 (1px), cells h-8 rounded-sm (6px), dark text grey-900 past mid-ramp, impossible cells rendered empty. Keep the static class map: an inline gridTemplateColumns counts against the ds-coverage inline-style ratchet.tailwind-preset.ts): rounded-sm=6, rounded-md=12, rounded-xl=24. Panels are rounded-md, not 6px.repeat(auto-fill,minmax(200px,240px)), left-aligned), plot 96px tall, bar max-w-[22px], 6 columns UTG→BB.| State | Treatment |
|---|---|
| Selected stat | Highlighted in both readout row and tab (single source of truth: selectedStat) |
| Hover | action.hover on readout rows and tabs; series card border paper.elevation8 |
| No spot | Hatched track + - in the value column + tooltip gtoHud.noSpot. Never 0. |
| Matrix impossible cell | Empty cell (reports convention for the diagonal) — no hatch at grid scale |
| Chart hover | DS ComposedChart per-card cursor + tooltip. No cross-card sync. |
flex-wrap: the readout drops under the title/solution block when it no longer fits (~lg).overflow-x-auto) — never wraps to two lines.md; matrix keeps its own overflow-x-auto.sm (basis-16 label, min-w-[50px] track, w-8 value); series cards go one per row.sm: switch to the single-strip layout (chips wrap) regardless of the desktop variant.MAIN_STATS; panels read STATS_BY_POSITION / VS_ACTION_TABLES / NBET_MATRICES. Statics stay until the BE endpoint lands (TODO(api) comment already in gto-hud-data.ts).gtoHud.readout.handsDealt ("of hands dealt"), gtoHud.readout.unopenedPots ("of unopened pots"), gtoHud.readout.facingAction ("facing an action"), and gtoHud.header.howTo ("How to use GTO HUD"). Add to en.json + descriptions.json only; never edit an existing value in place (the i18n guard rejects it).gametypes.groups.plo4, common.formatLabel.6max, gametypeLabelId(token), common.bb.3BET shows the matrix and the vs-table.aria-pressed / data attribute).- and not 0 (keep the existing assertion).pnpm nx run gto-wizard-web:type-check · test · oxlint · oxlint:cipnpm eslint apps/gto-wizard-web/src/pages/gto-hud --max-warnings 0pnpm nx run i18n:validate-i18n (descriptions.json must stay canonically sorted)Select.Dropdown/List/Item:
paper-elevation4 surface, shadow elevation9, radius 6, width var(--anchor-width) (production
adds min-w-72), sideOffset 4, z 1350, list p-1 gap-0.5,
max-h min(available,368px), item h-5 px-1 py-1.5 gap-1.5 rounded-xsm at 12px/500/.15px,
hover white 8%, highlighted white 12%. The trigger's open state is border primary-main +
the Field label turning primary-main.node tools/scripts/ds-coverage.mjs --check — deleting the left column should reduce arbitraryPx; re-baseline only with a justification line in the commit