# DS audit — TAB STRIP + SELECT (mock: apps/artifacts/plo-gto-hud-redesign/v2.html)

Read against `libs/ui/src/components/ui/tabs/**`, `libs/ui/src/components/ui/select/**`,
`libs/ui/src/styles/listbox-styles.ts`, `libs/ui/src/components/ui/field/**`,
`libs/ui/src/theme/design-tokens/*`. Mock line numbers = v2.html.

| # | Mock element (selector/line) | Mock value | DS truth (file:line) | Verdict | Exact fix |
|---|---|---|---|---|---|
| 1 | `.ds-tab` (v2:299) | `padding:0 16px` + `align-items:center`, h38 | `tabs.styles.ts:234` `h-[38px] min-w-[90px] px-[16px]` + `:243` `pt-[6px] pb-[9px]` | MISMATCH | `padding:6px 16px 9px` — DS label sits 1.5px HIGHER than the mock (content box 23px, y=6..29). Every label in the strip is off. |
| 2 | `renderTabs()` (v2:655-688), called from `render()` (v2:899) on every `select()` | full `innerHTML=''` rebuild → new `.tabs-ind` node each click | `list.tsx:196` one persistent `BaseTabs.Indicator`; `tabs.styles.ts:61` `transition-[left,top,width,height] duration-200 ease-out` | MISMATCH (mock understates motion) | DS pill *slides* 200ms; the mock never animates (fresh node) and drops focus off the clicked tab. Mutate `aria-selected`/`tabindex`/`data-active` in place instead of re-rendering, or reviewers approve a static pill. |
| 3 | `measureCorners()` (v2:740-755) | `classList.remove('tl-square','tr-square')` then re-apply after `400/2=200ms` for *both* corners | `panel.tsx:389-420` asymmetric: `square→round` applies immediately, `round→square` defers to `transitionend` on `left` (fallback 400ms); `root.tsx:161` `setCorners` dedupes so a corner that stays square is never touched | MISMATCH | Both top corners flash rounded on every tab click in the mock (visible 200ms seam). DS Panel already does this — implementer must use `<Tabs.Panel>`, not port the mock JS. |
| 4 | `.ds-select` (v2:329-334) | only `:hover` | `select.styles.ts:11` `focus-visible:…border-primary-main`, `:12` `data-[popup-open]:bg-enabledFill text-text-primary border-primary-main`, `:13` `aria-[invalid=true]:border-error-main`, `:14` `data-[disabled]:text-text-disabled cursor-default` | MISMATCH | Add `:focus-visible`/open state: `background:var(--input-fill);color:#fff;border-color:#aafbb2`. The OPEN trigger has a 1px `primary.main` border + white text — the mock's closed-only trigger hides the state the reviewer must sign off. |
| 5 | `.ds-select` (v2:329,331) | `display:inline-flex; min-width:260px` | `select.styles.ts:8` `flex w-full`; production consumer `gto-hud-header.tsx:97-99` `className="w-auto min-w-56 sm:w-full sm:min-w-0"` | MISMATCH + NOT-A-TOKEN | 260px is not on the spacing scale. Use `<Select.Trigger size="small" className="w-auto min-w-56">` (224px) or `min-w-64` (256px). |
| 6 | value `<span>` (v2:407) | no overflow handling | `select.styles.ts:16` `value: 'truncate …'` | HAND-ROLLED | Use `<Select.Value>`; add `overflow:hidden;text-overflow:ellipsis;white-space:nowrap` in the mock or long solution names silently widen the trigger. |
| 7 | `.ds-select .chev` (v2:334) | text glyph `&#9662;` at `font-size:10px` | `select.tsx:41-50` `<Select.Icon>` → `ChevronDownIcon`; `select.styles.ts:17` `text-text-secondary shrink-0`; `:31` `[&_svg]:size-4` (16px) | HAND-ROLLED | Inline the 16×16 `ChevronDownIcon` svg with `currentColor`, add `flex-shrink:0`. DS does **not** rotate the icon when open — don't design a flip. |
| 8 | `.field label` + `.q` (v2:70-73, 406) | `t-body4cap` 10px/uppercase, `gap:5px`, `margin-bottom:5px`; `.q` = 13px circle, 1px `text-disabled` border | `field.styles.ts:7-19` `Field.Root` `flex flex-col gap-0.5`, `Field.Label` = `typography['input/label']` (12px/100%/0.15px/**normal**) `flex items-center gap-1 min-h-4 px-2` + `group-has-[[data-popup-open]]:text-primary-main`; `field.tsx:27-41` `Label tooltip=` → `help-icon-tooltip.tsx:49` 12px `IconQuestionMarkCircle` at `action.active` | HAND-ROLLED + NOT-A-TOKEN | Use `<Field.Root><Field.Label tooltip={…}>` around the Select. Corrected CSS: label `font-size:12px;line-height:100%;letter-spacing:.15px;font-weight:400;gap:4px;min-height:16px;padding:0 8px`, root `gap:2px`, help icon 12px `rgba(168,168,177,.56)`. 5px/5px/13px trace to nothing. Also: the label turns `#aafbb2` while the dropdown is open. |
| 9 | `.tabs-arrow` (v2:288-294) | hover / active / disabled only | `list.tsx:90` `focus-visible:outline-none focus-visible:bg-states-focus focus-visible:text-text-primary` | MISMATCH | Add `.tabs-arrow:focus-visible{outline:none;background:var(--states-focus);color:#fff}` (harmless in practice — `tabIndex=-1` — but it is in the DS class string). |
| 10 | `.ds-tab` states (v2:306-311) | gated on `[aria-selected="false"]` only; no disabled rule | `tabs.styles.ts:216-224` every overlay/ring gated on `aria-[disabled=false]`; `:225` `aria-[disabled=true]:cursor-not-allowed aria-[disabled=true]:text-text-disabled` | MISMATCH | Add `.ds-tab[aria-disabled="true"]{color:var(--text-disabled);cursor:not-allowed}` and `:not([aria-disabled="true"])` on the three overlay rules. Relevant here: PLO5 / unavailable stats will need disabled tabs. |
| 11 | `.ds-tab` / `::before` (v2:301,303), `.tabs-arrow` (v2:291), `.ds-select` (v2:332) | `.15s linear` / bare `.15s` (→ `ease`) | `tabs.styles.ts:207,214` `transition-colors duration-150`; `list.tsx:89`; `select.styles.ts:8` `transition-colors` — Tailwind default easing `cubic-bezier(.4,0,.2,1)` (preset only *extends* easings: `tailwind-preset.ts:393-394`) | MISMATCH (cosmetic) | Use `cubic-bezier(.4,0,.2,1)`. For `.ds-select` transition `background-color,color,border-color` — the border changes on focus/open. |
| 12 | `.tabs{}` `.tab{}` `.tab-sep{}` (v2:106-115) | hand-rolled strip: `paper3` + `1px divider`, `radius sm`, `padding 4px`, `.tab.on` = `primary-focus` bg + inset green ring | no DS analogue (Tabs uses a sliding pill on `paper-elevation3`, no border, no green fill) | HAND-ROLLED (dead code) | Unused in v2 markup — delete. Left in, an implementer may copy the wrong strip. |
| 13 | spec table row "Stat tabs" (v2:460) | "Three `ToggleButtonGroup size="small"`" | `toggle-button.styles.ts:25` `h-7 py-2 px-3` (28px) — different component from what the mock renders (`.ds-tab` = DS Tabs, 38px pill) | MISMATCH (doc vs mock) | Pick one. The rendered mock is DS `Tabs` (`Root scrollable paperLevel=3 color=primary size=medium` + `List` + `Tab` + `Panel`); update the table or the mock, otherwise the implementer builds the wrong control. |
| 14 | tab buttons / panel (v2:663-665, 415) | `role=tab`, roving `tabindex`, `aria-selected`, `data-active`; panel has NO `role=tabpanel` / `id` / `aria-controls` / `tabindex=0` | `tab.tsx` + `panel.tsx` (base-ui `Tabs.Tab`/`Tabs.Panel` wire `aria-controls`/`aria-labelledby`/`id`/`tabindex` and `data-hidden`) | MISMATCH (mock only) | Free with DS; note it so no one hand-rolls the strip. Keyboard: mock's ←/→/Home/End + wrap matches base-ui; it activates on focus (base-ui default) ✓. |
| 15 | `.tabs-panel` (v2:321-322) | `padding:var(--panel)`, `display:flex`, `gap:var(--gutter)` | `tabs.styles.ts:301,329` panel = surface + radius only, no padding; docblock `:284-300` warns a consumer `display` utility un-hides a `keepMounted` panel (guarded by `data-[hidden]:hidden`) | MATCH (consumer className) | Keep `p-panel flex flex-col gap-panel` as `className` on `<Tabs.Panel>`; use `gap-panel` not `gap-gutter` (both 16px, but *inside* a panel the token is `panel` — `spacing.ts:gutter/panel`). Don't strip the DS `data-[hidden]:hidden`. |
| 16 | `.appbar` + `.variants` + `.nav` (v2:52-61, 392-396) | marked only by a CSS comment (`v2:51`); values `gap:18px`, `padding:10px`, `3px`, `4px 9px`, `6px 10px`, `.logo` `gap:7px` | not DS components; 18/9/7/3px are off the 4px scale (`spacing.ts`) | NOT-A-TOKEN (non-production chrome) | Add a **visible** marker (e.g. a `body4/cap` "mock chrome — not part of this page" badge or dashed outline on `.appbar`) so no one specs the real app bar from it. |

## Verified MATCHes (do not "fix")
`.tabs-list` `relative isolate inline-flex self-start gap:4px clip-path:inset(-9999px 0)` = `tabs.styles.ts:14-35`.
`.ds-tab` `h38/min-w90/px16`, `z-[1]`, `capitalize`, `text-secondary`, `aria-selected → #aafbb2` (= `colors.ts:20` electricGreen200) = `:195-234`.
`::before` `inset-x-0 top-0 bottom-4 radius-8`, `::after` `left/right/top 2px bottom 6px radius-6 1px #fff` = `:243`. Overlay colours white 8/12/16% = `colors.ts:167-171`.
`.tabs-ind` `paper3 #1B1B1D` (`colors.ts:185`, `paperLevel` default 3), `rounded-t-8`, 8px concave flares with the exact `radial-gradient(circle at top left,transparent 7.5px,black 8px)` masks, `transition left/top/width/height 200ms ease-out` = `tabs.styles.ts:52-71`.
`.tabs-arrow` `38×38`, `radius 8`, `rgba(168,168,177,.56)` (= `action.active`, `colors.ts:219`), hover `states-hover`+white, active `states-selected`+white, `disabled` transparent + `white/18%`, `svg 24px`, `left-0/right-0 top-1/2 -translate-y-1/2`, wrap `padding:0 48px` (= `px-12`) = `list.tsx:83-93,223`. `SCROLL_STEP_PX 160`, `EDGE_TOLERANCE_PX 1`, `TRANSITION_FALLBACK_MS 400` all copied correctly (`list.tsx:29`, `panel.tsx:59,73`).
`.tabs-panel` `paper3` + `rounded-b-12` + conditional square top corners = `tabs.styles.ts:329-352`.
`.t-btn-md` 14/143%/0.2px/600 = `typography.styles.ts:32`. `.ds-select` `h32 px8 py4 gap4 radius-6 14px/18px/500/.1px`, fills `rgba(168,168,177,.1)`→`.15` = `select.styles.ts:31` + `colors.ts:448-449`; `border:1px solid transparent` ✓.

## (a) DS components/props the mock should use
`Tabs.Root scrollable paperLevel={3} color="primary" size="medium"` + `Tabs.List aria-label` + `Tabs.Tab value disabled` + `Tabs.Panel value` (`libs/ui/src/components/ui/tabs`) — the mock's JS indicator/corner/chevron logic is all built in.
`Select` + `Select.Trigger size="small"` + `Select.Value` + `Select.Icon` + `Select.Dropdown/List/Item` (`libs/ui/src/components/ui/select`).
`Field.Root` + `Field.Label tooltip=` (→ `HelpIconTooltip`) instead of `.field label` + `.q`.
`Typography variant="button/medium"|"input/label"` instead of `.t-*` classes.

## (b) Untraceable values
Select `min-width:260px`; `.q` 13px + `text-disabled` border; `.field label` `gap:5px`/`margin-bottom:5px`; `.fields gap:12px` (ok, = spacing 3) but `.field{margin-top:12px}` is ad-hoc layout; app-bar `18px/10px/9px/7px/3px`.

## (c) Things the DS cannot express as-is
1. **Auto-hiding chevrons.** Mock toggles `.scrollable` from a live overflow check (`updateArrows`, v2:709-719) and `display:none`s the arrows when the strip fits. DS `scrollable` is a static prop: arrows are always rendered (disabled at the boundary) and the 48px side padding is permanent (`list.tsx:223`). Needs consumer-side `ResizeObserver` toggling the prop — or accept always-visible disabled arrows.
2. **Open-state dropdown spec (not in the mock at all).** Implementer must expect: portal + positioner `sideOffset={4}`, `alignItemWithTrigger={false}`, `z-index 1350`; popup `bg #1E1E21` (paper.elevation4), `box-shadow` `shadows.elevation9`, `radius 6px`, width = trigger width (`--anchor-width`), production adds `min-w-72` (288px); list `p-1 gap-0.5 max-h min(available,368px)`; item `h-20px px-1 py-1.5 gap-1.5 radius-4px`, 12px/500/0.15px `text-secondary`, hover `white 8%` + white text, highlighted `white 12%`, disabled `text-disabled` (`listbox-styles.ts:5-19`).
3. **Grouped tab strip.** DS `Tabs.List` has no group/separator part (v1's `.tab-sep` hairline). Three families in one strip ⇒ three `Tabs.Root`s (loses one sliding indicator) or a custom component.

## v1.html (older, brief)
`.ds-tab` keys off a `.on` class, no `capitalize`, no focus ring (`::after`), no focus/active overlays (v1:275-281) → misses `tabs.styles.ts:216-225`. `.tabs-panel{border-radius:0 0 12px 12px}` (v1:292) hard-squares BOTH top corners — wrong whenever the active tab isn't flush with a panel edge (`tabs.styles.ts:340-352`). `.tabs-scroll` (v1:291) has overflow but no chevrons and no 48px padding. `.tabs-ind` transitions only `left,width`; DS also `top,height` (`tabs.styles.ts:61`). Select block is identical to v2 → items 4-7 apply verbatim.
