Skip to content

feat(fairway): the premium facelift — every coach screen as a field sheet - #1933

Open
njrini99-code wants to merge 174 commits into
mainfrom
agent/frost-facelift
Open

feat(fairway): the premium facelift — every coach screen as a field sheet#1933
njrini99-code wants to merge 174 commits into
mainfrom
agent/frost-facelift

Conversation

@njrini99-code

Copy link
Copy Markdown
Owner

What this is

The Fairway premium facelift for the coach side of GolfHelm. Every coach screen was a masthead sitting on a deck of rounded cream boxes. The owner's words were that it looked basic and like the product was going backwards. This branch replaces that idiom with a written page language and rebuilds each screen against it.

Do not merge yet. This is opened for review at the owner's request.

The page language

docs/design/fairway-facelift/LANGUAGE.md is binding, and every screen spec cites it. One anatomy, in DOM order:

  1. A bare masthead on the canvas. No plinth. A verdict sentence built only from real fields, with names as links.
  2. Exactly one Surface, holding one real instrument.
  3. A bare ledger row of unequal, hairline-divided columns.
  4. A dense table.

Green is ink. Amber means over par or urgent. Nothing else is coloured.

Two rules earned their place the hard way and are worth reading before reviewing:

No dot-on-a-rail. A mark sitting on a full-width rail reads as a handle parked on a slider. Instruments use vertical ground, or a zero rule that marks genuinely rise above and fall below, or no ground at all. Every mark states its own value in mono beside it rather than asking the reader to measure it against a distant axis. The component that embodied the old pattern, StandingTrack, is deleted here.

The breakpoint rule is a floor, not a number. A split is legal only at the width where every column still holds its content whole. A fixed rail beside a flexible instrument may not split before xl. A fractional split shares the loss and may sit lower, but must be captured there. Verified at 768, 1024, 1280 and 1440, and on a phone.

Screens rebuilt

Coach home, calendar, roster, player detail, rounds library, round review, qualifiers, team stats, tasks and intelligence. Each has a spec under docs/design/fairway-facelift/screens/*.v3.md with a ## Result section recording every deviation and why.

Correctness fixes found along the way

These are not cosmetic. Each was a number or a sentence that lied.

  • A readout claimed "44.5 pts" over five rounds that were five unrelated single rounds by different players. Team headlines now aggregate into buckets the way the headline itself claims.
  • A sparkline's colour contradicted its own caption. The caption used a split-half average and the sparkline recomputed from endpoints. They now share one computation.
  • Two columns on the roster both read "Avg" and disagreed by three strokes, because one averaged the selected window and the other the whole career.
  • StandingBars clipped its own reference label to "Fiel…" everywhere it rendered, hiding which reference the bar was measured against.
  • The stored round narrative said "0 pars" for scorecard-only rounds, deriving a hole-by-hole fact from no holes at all.
  • A truncating name could not shrink inside ScoreField's identity track. truncate sets white-space: nowrap, which makes a flex item's minimum size its whole unbroken line.

Defence against real production data

Two golf_qualifiers rows carry a start_date in the year 60824, which a date column accepts. Plotted, they stretched the axis across roughly fifty-eight thousand years, collapsed every real bar to a hairline, and drove a monthly tick loop through about seven hundred thousand iterations. DOMAIN_LIMIT_YEARS and isPlottable keep such a row out of the domain and render it as "Dates outside the plotted range" instead of a bar, and MAX_TICK_STEPS in ScoreField is the backstop for any caller that forgets to bound its own domain. The rows themselves are still wrong and still there.

Mobile

Mobile is in scope. A wide table at 390px either scrolls sideways or truncates the column that identifies the row. Screens render two branches, a stacked list and the table, both always in the DOM with CSS choosing between them, so no breakpoint is read at runtime and the server render and first client paint agree.

Two things a reviewer should know before reading the history

  1. Two commits titled as docs changes carry source deletions. 02a9266fd and 82ce0ab49 swept up files because staging was too broad. 02a9266fd deleted the player's round row component and its test, which is a real mistake: an implementer then built on a tree where the player's row had vanished, so the coach's dense table briefly rendered for players too. Both the component and its test are restored, and the role branch is fixed. A third, 83d72a570, looks alarming at twenty-nine files but is the intended dead-code sweep, recorded afterwards in 77927200e.
  2. The Ask CoachHelm launcher sits on top of page content in the bottom-right corner at every scroll position, including over a readout on the coach home. It is pinned to the viewport, so page padding cannot clear a row in the middle of a scrolling list. Fixing it means changing the launcher, which is a product decision and is deliberately not made here.

Verification

Per-screen: scoped vitest, eslint on changed files, and desktop and phone captures read at full size at each of the four desktop widths and on a phone. Feature docs updated through npm run knowledge:map. Repo-wide route-reachability and storage-buckets-tracked were red and are addressed; the remaining reachability entries are CoachHelm components orphaned by the intelligence rebuild.

Full typecheck, lint and build run under the required GitHub checks on this branch.

Still landing

Several implementers are still committing into this branch, so expect further commits on the same PR.

🤖 Generated with Claude Code

https://claude.ai/code/session_011zGybqwusmeKC2jJhsU9JC

njrini99-code and others added 30 commits September 9, 2026 23:49
…roup, StatMatrix, floating dock, capture tooling

Phase 1 of the Fairway premium facelift (docs/design/fairway-facelift/BRIEF.md):

- design-tokens: semantic depth ladder (--fw-depth-1..4), --fw-shadow-control,
  one frost recipe (tint, saturate, blur tiers, backgrounds, rim, edge light,
  contact shadows, selection) with dark-scheme values.
- globals: .fw-frost + subtle/floating/modal/bar/selection tiers, .fw-dock-fade,
  phone blur downshift, reduced-transparency / no-backdrop / forced-colors
  fallbacks; --fw-mobile-nav-height now reserves the 60px dock + 10px float.
- GlassSurface: renders the shared frost tiers (tier, edgeHighlight, tint,
  interactive) instead of a private recipe; exports GlassSurfaceTier.
- New surfaces/InsetGroup (grouped rows with seams) and modules/StatMatrix
  (one object for related values) to replace nested mini-cards.
- Button gains shape="block" (14px CTA); Sheet gains material="frost".
- FairwayBottomNav becomes the floating dock: 60px capsule, 16px side inset,
  floats above the safe area, contact fade, compact tinted active island.
- scripts/ui-intelligence/capture-golf-facelift.mjs: coach + player, phone +
  desktop, fold + full, tabs and sheets, code graph per route (npm run
  ui:facelift:capture).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011zGybqwusmeKC2jJhsU9JC
The "which component solves this?" map (brief §22): bestFor / avoidFor /
replaces / mobile / desktop per component, plus the question → visual
table. registry.test.ts keeps every name a real Fairway export.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011zGybqwusmeKC2jJhsU9JC
…en specs

- --fw-mobile-nav-height now reserves the dock's real box (10px top pad +
  60px capsule + 10px float gap + safe area) and AppShell's shell offset
  matches; the old max() under-reserved 10px on notched phones.
- next.config: logging.serverFunctions=false — the dev server echoed every
  Server Function's arguments, including the password passed to loginAction,
  into its log.
- Capture script: resumable manifest, hydration-safe login with retries,
  re-login on session lapse, dev badge hidden.
- docs/design/fairway-facelift: AUDIT.md (competing surfaces) and the first
  screen specs (coach home, roster, rounds library, player dossier, tasks,
  qualifiers, team stats, round detail, calendar desktop).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011zGybqwusmeKC2jJhsU9JC
…gacy glass chrome; audit + specs

Next 16 defaults experimental.staleTimes.dynamic to 0, so every dock or
rail tab tap refetched the (cookie-dynamic) dashboard page and repainted
its loading skeleton, even hopping between two tabs. 60s of client router
cache serves the hop back; revalidatePath and router.refresh() still purge.

.fw-glass-chrome is now paint-only alias of the Frost subtle tier so its
19 consumers stop carrying a second recipe. AUDIT.md gains the hydration,
dead-code and performance sections; two more screen specs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011zGybqwusmeKC2jJhsU9JC
…ss, Meter, ResizableWorkspace, frost Toolbar

Menu and Tooltip wrap the Radix primitives already in the tree and float
on the frost tiers; ScrollArea is CSS/DOM over the existing scroll-fade
hook; Progress (process) and Meter (measure) are two distinct feedback
primitives; ResizableWorkspace is a CSS-grid three-pane shell with pointer
and keyboard resizing and persisted layout. Toolbar gains
material="frost" (default path unchanged). Registry entries added.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011zGybqwusmeKC2jJhsU9JC
…toolbar, seam rows

Five KPI cards, floating trend pill and per-group cards become ViewHeader,
StatStrip (delta chips fold the trend in), a TickerStrip of the last 15
scored rounds on desktop, one Toolbar (search, player, round type, month/
week) and ONE surface with sticky seam headers and hairline rows. Group
sparklines only at 6+ points; null to-par rounds no longer plot as even par.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011zGybqwusmeKC2jJhsU9JC
…h inline drill

Overdue banner, Templates card and quick-stats rail are gone. StatMatrix
(Open · Active · Completed · Overdue, the overdue cell jumps to Active),
Toolbar with a status Segmented and one multi-select Category filter, and
one surface of rows: title, assignee progress, due, category, overflow.
Row click expands an inline DrillPanel on desktop or a Sheet on phone;
From template lives in the header menu and opens a Sheet.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011zGybqwusmeKC2jJhsU9JC
Per-qualifier card galleries become one matte surface of rows (name ·
dates · spots · course · status) under Active and Concluded seams; the
live or next qualifier is one elevated block above the toolbar, derived
from the unfiltered list so the filters below it cannot hide it. Search and
status filters live in the Toolbar.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011zGybqwusmeKC2jJhsU9JC
… MatrixBoard rows

Eight tall player cards become ViewHeader, a header surface (StatMatrix +
who needs attention), one Toolbar (search, sort, needs-attention, export)
and a MatrixBoard with one row per player (name · SG total · trend
sparkline · focus · signal), row click expands the detail. The page derives
recent_scores from data it already fetched; no new query.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011zGybqwusmeKC2jJhsU9JC
FairwaySidebar and FairwayShellSkeleton take tone='dark' | 'cream'; the
golf dashboard shell passes cream so the rail sits beside the cream canvas
as one instrument, with the active row as a frost-selection capsule.
Baseball and admin keep the warm-black default; nav-* tokens are untouched.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011zGybqwusmeKC2jJhsU9JC
loading.tsx and page.tsx's own loading branch still shape-matched the
retired filter-pills + card-list + Templates-rail composition. Reshape
both skeletons to mirror FairwayTasks: masthead -> StatMatrix -> toolbar
-> one matte Surface of seam rows, so the swap into real data has no
layout shift.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011zGybqwusmeKC2jJhsU9JC
… pulse board, recent rounds

Twelve stacked cards become a ViewHeader with one primary action (New
event) and an overflow menu, then Today beside Team performance (window
Segmented, StatMatrix, trend), Team pulse as a MatrixBoard beside the
latest notifications, and Recent rounds as a TickerStrip over seam rows.
The skeleton mirrors the new layout. Invite notice only below 3 players.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011zGybqwusmeKC2jJhsU9JC
The Suspense fallback still modeled the pre-facelift five-tile grid and a
per-group Surface. Reuse the real StatStrip primitive (RSC-safe, no hooks)
for the hero so phone gets its rail shape and desktop its grid, and collapse
the ledger placeholder into ONE Surface with a couple of seam-header shells
and 8 hairline-divided rows, matching FairwayRoundsLibrary's live layout.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011zGybqwusmeKC2jJhsU9JC
Labeled bar rows (You · Team · Tour) with values, diverging from zero for
signed strokes-gained metrics, a cold-start explanation instead of a
missing marker, and a visually hidden table for assistive tech. Swapped
into the standing drills, insight and evidence panels, goal and focus-area
cards and the filmstrip review; StandingTrack loses its redundant pin so
the Spine and SG instrument draw a plain fill bar. Legacy StandingBar
Card/Inline/Hero and StandingStrip carry deprecation headers.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011zGybqwusmeKC2jJhsU9JC
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011zGybqwusmeKC2jJhsU9JC
…l 44px touch target

MatrixBoard: add an optional per-row action slot (MatrixBoardRow.actions)
rendered as a sibling of the row's press-target button, never nested
inside it, so a caller-supplied overflow Menu/IconButton stays a real,
independently-focusable control. Add external row-expand control
(expandedRowId + onExpandedRowChange on MatrixBoardProps) so a consumer
can drive which row is expanded from its own state; omitting
expandedRowId preserves today's fully independent per-row internal
useState exactly as before. All existing MatrixBoard tests still pass;
5 new tests cover the actions slot (present/absent, sibling not nested)
and both the controlled and uncontrolled expand paths.

FilterPill: the sm/md pill's 30px/36px min-height fails the WCAG 2.2 AA
(2.5.8) 44px touch-target minimum on phones. Expand the hit area to 44px
under `(pointer: coarse)` only, mirroring the identical fix already
applied to Button `sm` and Segmented `sm`/`md` — the pill's visual height
on a mouse-driven desktop is unchanged. Applies to both the button and
link (FilterPillLink) forms. New filter-pill.test.tsx covers the
coarse-pointer expansion plus baseline toggle/disabled/count behavior.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011zGybqwusmeKC2jJhsU9JC
…Sheet

Adopts MatrixBoard's row action slot and controlled expand: the player
actions menu is a sibling control on every row, desktop keeps the inline
expand, below sm a tap opens a bottom Sheet with the same detail body.
Trend sparkline pinned to the 96px track.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011zGybqwusmeKC2jJhsU9JC
`stickyTop` was a plain number, so no consumer could stick a Toolbar
under the app top bar, whose real offset is a CSS calc() baking in
`env(safe-area-inset-top)` (`--golf-mobile-header-offset`) plus an
optional hub sub-nav strip (`--fw-hub-subnav-offset`). Accept
`stickyTop?: number | string`: a number keeps today's plain-px behavior
unchanged; a string (e.g.
"calc(var(--golf-mobile-header-offset) + var(--fw-hub-subnav-offset, 0px))")
is used verbatim as the CSS `top` value. The stuck-detection
IntersectionObserver's `rootMargin` offset is negated via a wrapping
`calc()` for the string path (a valid CSS <length-percentage>) instead
of the numeric `+1px` arithmetic, which only applies to the number path.

Two new tests: a calc() string is applied verbatim to `top`, and the
0px numeric default is unchanged when `stickyTop` is omitted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011zGybqwusmeKC2jJhsU9JC
…d frost when stuck

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011zGybqwusmeKC2jJhsU9JC
Round detail (FairwayRoundDetail.tsx): collapse the three-pill masthead to
one primary "Open full review" plus an overflow Menu ("All stats", "Change
round type"); replace the 3-panel InstrumentCluster hero with one green
InstrumentPanel (score, grade dots, course · date · player, ai_recap
caption) followed by Filmstrip + a 5-up StatMatrix when hole data exists, or
a single InlineNotice when it doesn't; replace the "Areas to work on"
card+EmptyState+duplicate-CTA with an RxCard rendered only when the review
has real focus areas; add a phone-only sticky "Open full review" CTA
(Button shape="block"). Scorecard/ScoringDistribution/Pulse are unchanged —
they aren't in the removal list, have pinned regression tests, and already
self-hide with no hole data.

RoundTypeEditor.tsx: convert to a parent-controlled open/onOpenChange API so
FairwayRoundDetail's overflow menu can be its trigger, instead of the
component managing its own closed-state button. Add RoundTypeEditorTrigger,
a small self-toggling wrapper preserving the old default behavior for the
one other consumer with no menu of its own to open it from
(rounds/continue/[id]/page.tsx, a server component rendering into a client
child with no surrounding chrome) — that route's chrome is out of scope
here.

Review page (rounds/[id]/review/page.tsx): both "Refresh" buttons become an
icon-only IconButton in the header actions; the bottom "All Stats" block
button becomes a quiet ghost text link.

Updated the two Suspense loading.tsx skeletons under rounds/[id] to match
the new masthead/hero shapes, and the round-type mounting test to open the
overflow menu (Radix menuitem role, portalled to document.body) instead of
querying a standalone button.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011zGybqwusmeKC2jJhsU9JC
…rd + one analysis Bento

Standalone StatMatrix header (sticky on desktop), MatrixBoard with no
internal KPI band, and one Bento (tornado, fundamentals, putts by
distance, approach proximity) replace six boxes. One primary action;
export and Ask CoachHelm live in the overflow Menu. Trajectory cell
shows InsufficientData until a player clears the 8-round gate. Loading
skeleton matches the new composition.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011zGybqwusmeKC2jJhsU9JC
A page whose body text is under 40 chars is recorded as status "blank"
so a silent login or render failure is not mistaken for a finished
capture.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011zGybqwusmeKC2jJhsU9JC
…EF §15.10)

Five static guards in src/test/static/fairway-facelift-ratchets.test.ts,
following the existing readFileSync-scan pattern in that directory (e.g.
golf-conditional-redirect.test.ts). Each guard ships an explicit
allowlist of every violation that exists today, so the suite is green
now; any NEW violation anywhere not already on a list fails the build.
Every failure prints the offending file:line plus a one-line fix hint.

1. No `@/components/ui/{button,input,confirm-dialog,skeleton,
   dropdown-menu,row-actions-menu}` import under src/components/fairway
   or the golf dashboard tree — 25 current imports across 21 files.
2. No JSX use of the legacy dot-on-a-rail standing family (StandingStrip,
   StandingTrack, the v3 StandingBar Card/Inline/Hero variants) outside
   its own deprecated file/test — 1 remaining consumer
   (spine-stage/StandingDrill.tsx still renders <StandingTrack>).
   StandingStrip and the v3 Card/Inline/Hero variants already have zero
   external consumers.
3. No hand-rolled `backdrop-filter`/`backdropFilter` outside
   globals.css and the overlays CSS — repo-wide (not fairway-scoped,
   matching AUDIT.md's "six bespoke recipes" being a repo-wide finding):
   18 files (8 CSS/module.css, 10 .tsx inline styles), spanning golf auth,
   baseball onboarding, marketing and admin surfaces in addition to
   Fairway's own Toolbar stuck-glass recipe.
4. No `position: sticky` element whose className also carries a
   blur-tier class (fw-glass-chrome / bare fw-frost / fw-glass-strong /
   fw-glass-regular) — 10 files, mostly sticky calendar headers/footers
   on fw-glass-chrome, plus Toolbar's own tested sticky+frost combo.
5. Every fairway barrel's component-shaped export (PascalCase,
   function/forwardRef/memo-shaped) is named in registry.ts — the
   reverse of registry.test.ts's existing "every registry name is a
   real export" check, so it lives in this file instead of duplicating
   that one. 103 unregistered component exports today, mostly the
   several barrels (view-header, calendar, forms, command,
   notifications, cards-insight, data-table) whose own doc comments
   already say "ADDITIVE ONLY — nothing existing imports this," staged
   ahead of registry entries.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011zGybqwusmeKC2jJhsU9JC
…te stage, continuous date rail

Golf mobile lane of the Fairway facelift (docs/design/fairway-facelift/
screens/calendar.mobile.md and dock.mobile.md, written first).

Calendar (phone):
- Masthead is one matte sticky bar (bg-surface + foot hairline) on the
  --fw-z-sticky tier; the legacy .fw-glass-chrome + arbitrary box-shadow
  are gone. Matte, not frost, on purpose: a backdrop-filter over the
  scrolling stage is re-blurred every scroll frame on a phone.
- FairwayDayStrip is one continuous sunken rail with a tinted matte
  island for the selected day; density dots come from the canonical
  eventPresentation.ts tone table (the strip's and the drawer's private
  copies are removed); selection haptic fires only on an actual change.
- The agenda renders the whole period inside ONE matte Surface with
  hairline day sections and opaque pinned day headings (no per-day
  cards, no blur inside the stage, no z-[8]/rounded-xl/inline shadows);
  the member rail is a hairline row; the month sits on the same stage;
  the skeleton mirrors the new first paint.
- The event sheet is Sheet material="frost" (bottom on phone; the desktop
  right inspector stays matte by the primitive's own rule): matte sticky
  header, Your response as one InsetGroup, metadata InsetGroup, the
  coach's responses as StatMatrix (its first consumer), People / Files /
  Attendance as InsetGroup rows, sticky Sheet.Footer block CTA.

Performance (owner requirement):
- People and Files mount after the sheet settles (onAnimationEnd, 360 ms
  fallback for reduced motion), so the open translate animates a light
  tree; the only blur is the settled frost panel.
- One getEventRSVP per open: EventPeopleSection accepts the coach's
  attendees as a prop and fetches for itself only for a player or as its
  Retry path.
- The agenda's minute clock lives in today's rows only (TodayRows);
  BucketRows and FairwayEventCard are memoized; visibleBuckets is
  memoized with a Set. No framer layout under pages/calendar.

Dock and More sheet:
- FairwayBottomNav imports matchActive from more-nav.ts (one matcher),
  More is a native control sharing the tabs' class and selection haptic,
  the haptic is skipped when re-tapping the active tab, the active island
  is capped at a third of the dock.
- MoreNavSheet is Sheet material="frost" (no fw-glass-chrome); each rail
  section is one InsetGroup of seam rows with bare glyphs, no icon tiles.
- MoreSheetFooter drops the legacy ui/button; Sign out is a quiet danger
  text row.

Verified: vitest app-shell (79) + pages/calendar + mobile audit (303),
eslint on every changed component, tsc --noEmit clean.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGiUYKH3fXJrVnxPxH2h8Z
…e sticky tier, one RSVP fetch per open

- The phone FAB moves from z-[19] to z-[var(--fw-z-sticky)] and from an
  inline box-shadow to the shadow-raise token; its bottom offset still
  follows --fw-mobile-nav-height.
- The coach's getEventRSVP result now also feeds the drawer's People
  section (attendees prop): null while in flight, the list once loaded,
  undefined on failure so the section's own Retry path takes over.

Kept separate from the calendar/dock commit so the desktop calendar work
can rebase over this file alone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGiUYKH3fXJrVnxPxH2h8Z
New round entry uses the Fairway Input. The three ui/confirm-dialog
consumers (general settings, notification settings, classes) move to
ModalShell-based confirms. ChartTooltip uses the glass blur utility and
the strong glass background token instead of hardcoded values. The
command palette's glass surface becomes a thin wrapper over the
canonical GlassSurface (exported as CommandGlassSurface; the command
barrel keeps the GlassSurface alias). InstrumentPanel's headers describe
the matte recipe it actually renders.

Left in place: four ui/button uses in new round entry that pass
haptic="none", which the Fairway Button cannot express yet; the
onboarding/join/admin loading screens, which the design-system rule
keeps on the legacy skeleton.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011zGybqwusmeKC2jJhsU9JC
njrini99-code and others added 30 commits September 10, 2026 19:33
The screen was a masthead over a stack of rounded cards. It is now the
four regions round-review.v3.md asks for, and it reads as a field sheet:
a bare masthead with the verdict typeset large, ONE stage carrying a new
page-local instrument, three ledger columns divided by vertical
hairlines, and a dense hole-by-hole table.

RoundShape is the instrument, and it is genuinely new work rather than a
chart dropped in a box: percentage-geometry columns that draw the round's
shape from `momentumData` — a bar per hole above or below the par line,
amber over, green under, deep green for a hole two or more under — with
the cumulative running total drawn across them as one non-scaling
polyline and the finishing figure labelled where the line ends. Its
column model serves the degraded stage too: a scorecard-only round plots
its neighbouring rounds through the same instrument instead of a
different one.

Every number, bar and clause is sourced. The verdict names the best
stretch and the worst three-hole window from the hole deltas and drops
either clause when the holes cannot support it; a scorecard-only round
says so in a sentence and grows no invented series. Readout deltas come
from `getStatAverages`, the player's own recent rounds, because the trend
row carries only score. `round-shape.ts` holds all of it as pure
functions so the arithmetic is testable without a DOM.

`RoundSGSummary` and `ReviewBreakdown` keep their data behind the
masthead's "Full breakdown" rather than being deleted for the sake of the
new composition. The retired `FilmstripReview`/`ReviewHero` pair left one
audit test reading a file that no longer exists; it now asserts the
affordance that replaced the hint copy.

tsc clean, eslint clean over the changed files, 204 tests passing across
the round review and review page scopes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011zGybqwusmeKC2jJhsU9JC
… trend

The stage's ScoreField row pairs a window-filtered avg/bars series with a
recent_trend delta that is deliberately never re-derived per window (it has
to match the Attention ledger/table/masthead, which are all all-time) — so a
narrowed window could show one or two rounds beside a trend describing a
much longer history, with nothing on screen saying so. Flagged by the
facelift lead against their own Home-page fix for the same aggregation-
mismatch class (headline number vs. the series next to it).

Recomputing trend per window was considered and rejected: the canonical
five-vs-five classifier needs ~8 in-window rounds for a signal, so most
players would read "no read" at this screen's 90D default, and the stage's
own trend could then disagree with the ledger/table for the same player in
the same viewport — reintroducing the exact contradiction the all-time
choice was made to prevent. Fixed by stating the split in the stage's
caption instead: no data or classification changes.

Also re-verified the ledger's xl split against LANGUAGE.md's breakpoint rule,
rewritten as a floor while this screen was mid-build (00a73b9): captured
768/1024/1280/1440 and confirmed no player name clips in either ledger
column or the table at any width. roster.v3.md's Result section is updated
to match the current rule text and records both fixes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011zGybqwusmeKC2jJhsU9JC
`loading.tsx` is the first thing a navigation to this screen paints, and it
was reproducing the old page: a `max-w-6xl` shell, a centred bordered card
with an icon stack, a 3-up mini-stat row and two stacked rows. The v3 field
sheet has no cards and a different shell, so every visit flashed a layout
the page never renders and then reflowed.

It now mirrors the page's own loading branch exactly — the same shell, the
masthead silhouette, one stage block — so the route fallback hands off to
the page with no shape change.

Also records in round-review.v3.md that removing this page's framer-motion
wrappers left `golf/dashboard/premium-components.tsx` reachable from no
route. It was that file's only importer. Deleting a module in the shared
dashboard tree is outside this screen's ownership, so it is reported here
rather than removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011zGybqwusmeKC2jJhsU9JC
The screen was a hero card above a toolbar above one Surface holding two
seam-lists: four stacked containers that said nothing a table would not.
It now follows the page language. A bare masthead on canvas states the
verdict, ONE Surface holds the stage, a bare ledger row of hairline
columns sits under it, and the dense table closes the page.

The stage is QualifyingField: every qualifier on one shared date axis,
each bar running from the entry deadline through the last day of play.
The pale half is the waiting period and is drawn only when
entry_deadline was actually recorded, so a missing column reads as
missing rather than as a period we invented. entry_deadline,
selection_state, selection_slots_total, selection_slots_coach_pick and
num_rounds have been arriving on every page load and rendering nowhere;
this is where they start counting.

There are no per-row rails. The ground is vertical month gridlines. A
rail under every bar turns a short mark into a handle parked on a
slider, which is the one shape this instrument must never produce. A
one-day qualifier is a position rather than a length, so it renders as a
stroke carrying its own date, and every mark states its own window in
mono beside it instead of asking a coach to measure four percent against
a tick strip at the bottom of the field.

Production carries two qualifier rows dated year 60824, which a date
column accepts. Plotted, they stretched the axis across fifty-eight
thousand years, collapsed every real bar, and drove the monthly tick
loop through roughly seven hundred thousand iterations. DOMAIN_LIMIT_YEARS
and isPlottable keep such a row out of the domain and state its own
condition in place of a bar; MAX_TICK_STEPS in ScoreField is the
backstop for any caller that forgets to bound its domain.

The table renders two branches, a stacked list below md and the table at
md and up, both always in the DOM with CSS choosing between them. A
nine-column table at 390px either scrolls sideways or truncates the one
column that identifies the row. Nothing reads a breakpoint at runtime.

`today` arrives as a bare YYYY-MM-DD string from the route, so no clock
is read during render and the server markup and the first client paint
agree.

Verified: 58 tests across the qualifiers suite, the mapped
src/test/coachhelm/v3/qualifying.test.ts check (15), tsc clean, eslint
clean, and desktop and phone captures reviewed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011zGybqwusmeKC2jJhsU9JC
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011zGybqwusmeKC2jJhsU9JC
Round detail draws the same picture round review does, so the instrument
stops being page-local. `RoundShape.tsx` becomes `HoleField.tsx` and is
rewritten to stand on its own: it declares `HoleFieldColumn`,
`HoleFieldPoint`, `HoleFieldLine` and `HoleFieldDivider`, owns
`polylinePoints` and `holeFieldCap`, and imports nothing from this page.
The dependency runs one way, type-only, from `round-shape.ts` into the
instrument. It is still not in the fairway barrel or the registry.

Its props stay a column model rather than `HoleBreakdown[]`.
`HoleBreakdown` belongs to `round-review-system`, so taking it would tie
every future caller to the review's view model, which is the coupling a
shared instrument has to avoid. A caller maps what it has into columns
and keeps captions and headings on its own side. `cap` is now optional
and derives from the columns, so rendering the field needs no helper
import at all.

Two tests carry the facts this screen can get wrong. A round can report
`holes_played: 18` while having zero `golf_holes` rows, so a regression
test pairs that count with an empty `holeByHole` and asserts the
scorecard-only verdict, no instrument and no table: every hole-level
branch reads the rows, never the count. And `hole-field-real-round`
carries the 18 real rows of round 2f343331 out of the database and
asserts the whole path against them, including a genuine three-way tie
for the worst window and a level run that cannot claim to be under par.

tsc clean, eslint clean, 214 tests passing across the round review and
review page scopes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011zGybqwusmeKC2jJhsU9JC
The stage heads a column Avg and so does the table, and they are
different numbers: the stage averages only the selected window while the
table averages the whole career. On the live roster that is Mason Rivers
at 78.0 above and 75.6 below, three strokes apart, under one word. The
stage's eyebrow scopes itself to the window but cannot scope a table
eight hundred pixels further down the page.

The table column is now "Avg all-time".

Verified: 69 tests across the roster suite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011zGybqwusmeKC2jJhsU9JC
Team stats asked its question in the wrong order. The roster board came
first and the team's strokes gained sat two screens below it in a Bento
cell, so a coach read nine rank columns before learning which column
mattered, then had to hold the leak in their head while scrolling back.

The page is now one instrument. CategoryField puts two registers on ONE
column grid: the team's signed strokes gained per category on top, the
roster's ranks in the same five columns below. The eye falls down the
amber column and lands on the players who own it, with no memory step in
between. Around it, a bare masthead whose verdict names the leak, its
owner and the roster's trajectory; three bare readouts; a hairline
ledger of where it leaks, who leads each and the fundamentals; a dense
category-detail table where a row opens; and the two leak maps as a bare
diptych, a different subject that keeps its charts and loses its boxes.

Deleted, not restyled: ViewHeader on the populated path, the sticky
StatMatrix band, MatrixBoard, the Bento and all four BentoCells, the
InstrumentPanel around the leak charts, the RailBars plus nested
StatMatrix pair, the duplicate sgTakeaway caption, and the
useMediaQuery('(min-width: 940px)') hook. The header label switch is now
two spans gated by one CSS breakpoint, so exactly one label is in the
accessibility tree at any width and nothing reads a breakpoint at
runtime.

Honesty, kept and tightened. A missing strokes-gained reading renders an
en dash and never a zero-length bar, which would claim the team sits
exactly on the Tour baseline. A trend count of zero states the gate
rather than "0 climbing, 0 sliding". A failed rounds fetch is no longer
able to render as a cold start: the "appears once players log rounds"
sentence is gated strictly on a real cold start, and the arrow glyph is
gone from the most-improved label.

All page-level derivations move to a pure, JSX-free team-stats-logic.ts
with its own unit tests. CategoryField is deliberately page-local and is
a promotion candidate only: modules/index.ts, modules/types.ts and
registry.ts are untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011zGybqwusmeKC2jJhsU9JC
Replaces the masthead + StatMatrix + Toolbar + Surface-of-seam-rows deck
with the coach language: a bare masthead, ONE Surface holding the Due
field, a bare three-column ledger, then a dense table.

Due field is a new page-local instrument (not in modules/, the barrel or
registry.ts): one lane per person who still owes open dated work, on a
shared due-date axis with an interior Today rule. Overdue marks rise as
amber bars scaled by days late; a date not yet reached is a neutral
stroke. Ground is vertical gridlines, never a per-row rail. Work nobody
owns gets its own terminal lane rather than borrowing a name.

Every derivation moves to a pure, JSX-free tasks-field-logic.ts with 47
unit tests. The masthead's worst offender and each lane's "Worst late"
figure are one function at two call sites, so they cannot disagree.

Honesty and hydration:
- `today` is a bare YYYY-MM-DD prop resolved once by the route; nothing
  in the tree reads a clock during render and date-only columns parse at
  local midnight.
- Every count derives from `tasks` against that one day instead of the
  hook's fetch-time stats, which read a bare date as UTC midnight and so
  counted a task due TODAY as overdue in any US zone.
- A task is not late on its own due day.
- A load error outranks an empty list, which outranks a zero count.
- An impossible due date leaves the axis and keeps its place in Load,
  the ledger and the table.

Deletes the useMediaQuery desktop/phone fork: both table branches are in
the DOM with CSS choosing. Both loading states now render one shared
FairwayTasksSkeleton so neither can drift from the composition.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011zGybqwusmeKC2jJhsU9JC
The login step filled both fields and clicked Sign in. On a busy dev
server the fill lands before React hydrates, and a fill that lands before
hydration is discarded when React attaches: the form looks filled, the
button stays disabled, and the click retries against a control that will
never enable. Each attempt then burned its full thirty-second timeout, so
a failed login cost two minutes and returned "0/0 surfaces captured" with
no usable reason.

It now checks whether Sign in actually enabled, retypes both values if it
did not, gives up on an attempt in eight seconds instead of thirty, and
says which of the three ways the attempt failed.

Verified by capturing coach/round-review, which had just failed twice
this way and succeeded on the first attempt afterwards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011zGybqwusmeKC2jJhsU9JC
Read at full size at 1440, the first pass had six things wrong with it.

"AROUND THE GREEN" wrapped over three lines in a 4.5rem category track,
which is the clipping failure LANGUAGE.md's breakpoint floor exists to
prevent, and no viewport width fixes it because the track is fixed. The
header carries the head noun instead; the full name is still on the page
in the verdict, in the leak ledger and as the sort control's accessible
name, and the old "Short game" contradiction stays fixed.

The readouts rail carried three items where the coach home carries four,
and `justify-between` over a rail as tall as a nine-row field pushed them
so far apart the column read as empty rather than as a stack.

"Team SG / rd" printed its unit twice, once in the label and once inside
the value string. The label keeps the unit; the value is now the number.

The team register's identity line said "4 rds in 30d" beside strokes
gained pooled over every round a player has logged, which captions the
row with a window it is not measured over.

The stage legend said "Tap", which is not what a coach at 1440 does.

And every leak map printed "Leak Map" as a second heading under its own
SectionHead: that is `LeakMap`'s default title, the component's own name
leaking into the page. Both halves now pass their real title, kept in the
DOM as the chart's accessible name and hidden visually, so each half
shows one heading.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011zGybqwusmeKC2jJhsU9JC
… the rebuild

Two regressions from the field-sheet rewrite, found while re-verifying
against the lead's second note (both predate it — this rebuild fully
replaced FairwayCoachRoster.tsx and silently dropped two earlier fixes
made to the old MatrixBoard-era file):

- The container's symmetric `md:px-8` lost 191162a's `md:pr-24` right
  clearance for the fixed "Ask CoachHelm" pill (bottom-6/right-6, h-14,
  80px footprint). Restored as `md:pl-8 md:pr-24`.
- The Attention column's two empty-state strings used em dashes
  ("Nothing to assess yet — attention flags..."), against the house style
  191162a already established for this exact page (periods only).
  Rewritten as two sentences.

Also fixes a new regression from a concurrent commit (8371a55, "Avg
all-time"): at true phone width (393px) the wider header text pushed the
table's own overflow-x-auto past its visible width by 77px, silently
clipping the Trend value and the row's actions button — 0px page-level
overflow the whole time, since the clipping is internal to the table's
own scroll container (same failure shape as the earlier min-w-[720px]
bug). Root cause measured directly: the Player cell's inline intent pill
alone was ~110px of the ~282px cell, more than the table had left over.
Fixed by hiding the intent control below `md` (same trade already made
for Rounds/SG:Total/Focus) and giving the "Avg all-time" header an
`aria-label` so a phone-safe "All-time" span can render without changing
the accessible name the existing test asserts on.

Verified at 393px with a Playwright measurement (not just the page-level
overflow check that missed this the first time): table wrapper
scrollWidth now equals clientWidth (0px internal overflow); Trend and
Actions cells both land fully inside the visible 353px.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011zGybqwusmeKC2jJhsU9JC
…p finding

At 390px the field opened with the Scoring column off the right edge and
the active sort column cut in half, because the specified 3.25rem tracks
plus a legible identity floor are wider than the 326px a phone leaves
inside the page gutter and the Surface's padding. A swatch sliced down
the middle reads as broken, not as "there is more over here". 7.5rem of
identity plus five 2.5rem tracks is 320px, so every column and a whole
player name fit with no scrolling; a 2.5rem track still clears the 34px
rank swatch and the 36px team bar, and the scroller stays for anything
narrower.

The leak maps computed an honest one-line finding and then never showed
it: ChartFrame takes `takeaway` for the chart's spoken label only. Both
halves now print it above the plot as well.

Records the build and its thirteen deviations in the screen spec, and
writes the page's standing rules into the feature doc the registry names
for these files: one Surface, two registers on one shared column grid, an
en dash for absent, a stated gate instead of a zero trend count, a failed
fetch that is never a cold start, and no breakpoint read at runtime.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011zGybqwusmeKC2jJhsU9JC
Per facelift lead: the "Ask CoachHelm" launcher is fixed bottom-6/right-6
and pinned to the viewport at every scroll position, so page- or
table-local padding only clears content that happens to sit at the
page's own bottom edge — a row mid-list still passes under it while
scrolling. Asymmetric md:pl-8/md:pr-24 also shifted the whole masthead
left of the page's true center for a fix that doesn't hold. Reverting
FairwayCoachRoster.tsx's container back to symmetric md:px-8; the lead
is taking the launcher's own scroll-aware/icon-only behavior upstream as
a cross-page product decision.

The two em-dash-to-period copy fixes in roster-parts.tsx (kept, not part
of this revert) and the phone-width table fix (Avg-header aria-label +
hiding the intent pill below md) both stand — neither was in question.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011zGybqwusmeKC2jJhsU9JC
…module

`StandingTrack` was the instrument this pass replaced. Every consumer
migrated to `StandingBars`, its ratchet allowlist reached zero, and the
file then had no importer and no route, which is what
`route-reachability.test.ts` failed on. `premium-components.tsx` lost its
only importer when the round review stopped drawing a card. Both are
deleted rather than left as files a future reader has to prove dead again.

The ratchet keeps its `StandingTrack` exemption as an empty list rather
than dropping it, so the scan still reads as "everything except the
component's own file" if the file is ever restored.

Also fixes a false positive in storage-buckets-tracked. `bucket` is not
only a storage word: the notification feed's day grouping uses it for
today/yesterday/earlier, and the observability program uses it for a
severity classification. The option regex cannot tell either apart from a
storage id, so it reported "earlier" as a bucket the app addresses and no
migration creates. The four classification words are now excluded by
VALUE rather than by file, so a real storage bucket referenced from the
same file is still caught, and the KNOWN_MISSING_BUCKETS escape hatch the
test explicitly forbids for this purpose stays untouched.

route-reachability still reports three CoachHelm components orphaned by
the /intelligence rebuild. That screen is being rewritten right now, so
what it reaches is not yet settled and they are deliberately left alone.

Verified: storage-buckets-tracked 4/4, src/test/static 42/42.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011zGybqwusmeKC2jJhsU9JC
Appends the Result section IMPLEMENTING.md requires: the files, twelve
deviations with their reasons (one clock as a prop, derived counts over
the hook's UTC-midnight stats, not-late-on-the-due-day, the "Load"
rename off a colliding header word, no baseline rail, inline detail in
place of a portalled Sheet, the 5/3/4 ledger), the spec citations that
drifted, and what is not verified.

Also records the screen's contract in the feature doc the knowledge map
names for these paths.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011zGybqwusmeKC2jJhsU9JC
The label track was a fixed 44px. "Field Avg" at caption size needs about
55, so the reference row on every strokes-gained metric rendered "Fiel…"
and hid which reference the bar was measured against, which is the only
thing that row exists to say. Reported from the round review's third
ledger column at its 3/12 width, but it was never width-dependent: the
label was clipped everywhere the component rendered.

The track now sizes to the longest label in the group. The grid's first
column is already `auto`, so one width still serves every row and the
bars stay aligned; a max width keeps a pathological label from eating the
bar.

Verified: StandingBars 25/25.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011zGybqwusmeKC2jJhsU9JC
The screen was a deep-green Spine beside a three-pane ResizableWorkspace:
a queue, a dossier and a permanently docked chat panel, with a leak band
above them built on a taxonomy the filter chips beside it did not share.
Two client-measured breakpoints decided what rendered. It now follows the
page language. A bare masthead states the verdict with real links, ONE
Surface holds the stage, a bare ledger row of hairline columns sits under
it, and the dense table closes the page.

The stage is LeakRail: every raw signal category ranked by the strokes it
is putting at risk, summed from |strokeImpact| over the per-player signals
that carry one. There are no per-row rails. The ground is a vertical zero
rule and a vertical across-category mean rule, both spanning every row. A
rail under a bar turns a short mark into a handle parked on a slider, and
a tick sitting mid-track on eight rows is eight sliders. Every row states
its own figure in mono beside it instead of asking a coach to measure it
against a distant axis.

A roster roll-up's strokeImpact is the SUM of per-player leaks already in
the same list, so the rail and the queue skip it and the table still lists
it. Those are two different counts, so they are captioned apart: the Open
signals readout counts everything and says how many roll-ups that
includes; the Queue column counts what a coach can actually triage. A
category where no signal carries a measured impact reads "Not measured",
never a zero-length bar indistinguishable from a genuinely zero leak.

getSignalGroups failing forces groups to [], the same shape an all-clear
queue has, so every branch keyed on it is gated behind groupsError. Focus
areas and Outcomes awaiting read different queries and keep rendering with
their own states; blanking a read that succeeded is the same bug pointed
the other way.

Both useMediaQuery calls are gone with the components they gated. The
table renders a stacked list and a dense table, both always in the DOM
with CSS choosing, because at 390px a six-column table either scrolls
sideways or truncates the column that says whose signal it is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011zGybqwusmeKC2jJhsU9JC
Every clause in the summary is guarded on its own count being non-zero
except the par clause, which printed unconditionally. A scorecard-only
round carries a total score and no golf_holes rows, so its narrative read
"0 pars" — a hole-by-hole fact derived from no holes at all, and the only
clause in the sentence, leaving a dangling separator behind it.

The par count is the right clause to print at zero for a round we did
measure hole by hole: eighteen bogeys and no pars is a true sentence. So
the guard is on having hole rows, not on the count. When no clause
survives, the sentence ends after the score rather than trailing a stray
period.

The rendered page was already fixed in buildReviewViewModel; this is the
same rule at the source that writes the narrative.

Verified: 3 new tests, including the eighteen-bogey case that must still
say "0 pars".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011zGybqwusmeKC2jJhsU9JC
…ty track

`truncate` sets `white-space: nowrap`, which makes a flex item's
automatic minimum size its whole unbroken line. The row already carried
`min-w-0`, but the truncating child did not, so a long player name
refused to shrink and spilled out of the fixed identity track instead of
clipping inside it. Current rosters have short names, which is the only
reason this has not shown up in a capture.

Found by helmv3-20 from the other end: they traced 4px of overflow on
player development at every width, 1440 included, to a truncated
suggestion row whose longest line wanted 262px inside a 92px cell. The
general rule is worth keeping: a column that wraps holds its content
whole at any width, so `truncate` is what turns a layout question into a
breakpoint question.

Verified: ScoreField 5/5. Audited every other `truncate` on the facelift
screens; the rest already carry `min-w-0` or sit in a table cell with an
explicit max width.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011zGybqwusmeKC2jJhsU9JC
`role="row"` on the `<Link>` overrode the anchor's implicit link role, so a
screen reader announced a row of cells with nothing to say the row went
anywhere. The row is now a `div role="row"` with a real link on the player
name, and the anchor's `after` pseudo-element stretches across the row so the
whole row stays one click target without a div click handler or a second tab
stop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011zGybqwusmeKC2jJhsU9JC
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011zGybqwusmeKC2jJhsU9JC
`scoringTrend` is the only series on the player payload long enough to be
a stage: `recentRounds` is sliced to five rows, this one covers every
scored round. But the map formatted the date away, to "Sep 10" with no
year, so two rounds twelve months apart produce the same label and any
axis built from those strings is fabricated.

`date` now carries the round's own `round_date` as a bare YYYY-MM-DD.
Purely additive, one field on one map, no existing consumer changes.
`label` keeps its exact current text and is documented as display-only.

Asked for by helmv3-20 for the player home stage, which would otherwise
have had to plot ordinal position and label its axis "rounds in order".
Made here rather than there because this file is mine and the edit is two
lines, so a cross-session collision is not worth the coordination.

Verified: dashboard-data 24/24.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011zGybqwusmeKC2jJhsU9JC
Three CI type failures, all traceable to one decision: I withdrew the
full-project typecheck from every implementer while the machine was
under load. Vitest does not typecheck, so each of these passed its
local test run and failed only on the branch.

  * `FairwayRoundRow.tsx` and its test were untracked in the working
    tree while the committed `FairwayRoundsLibrary` imports them
    (TS2307, and a `check:cycles` unresolved-file failure on the same
    specifier). Both are restored here; the props match the committed
    call site exactly, including `seasonAvgToPar`.

  * `round-review-content.ts` does not declare `RoundData` or
    `HoleBreakdown` — it imports them, type-only, from the server-action
    module, so re-importing them from there was TS2459. The test now
    names their real home. `import type` is erased at compile, so this
    still does not pull `'use server'` into the test.

  * `dashboard-data.ts` had a SECOND `scoringTrend` construction site —
    the monthly team series at line 713 — that I missed when `date` was
    added to the interface. A month bucket has no single round date, so
    its `date` is the first of the bucket month: enough to place the
    point on a real axis, which the year-less `label` cannot do. The
    field doc now says so instead of claiming every point carries its
    own round's date.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011zGybqwusmeKC2jJhsU9JC
The v3 rebuild had unified both personas onto one dense <table>,
deleting FairwayRoundRow.tsx and losing the player's MicroBar, FIR/GIR
quick stats, and Best-of-period badge. That row belongs to a different
session's byte-identical-player contract, so it stays. The coach-only
dense table is the actual v3 deliverable; RoundsTable now takes only
`groups`/`onNavigate` (no `userRole` branch) and RoundsTableGroup grew
`bestId`/`hasMultiple`, used solely by the restored player row's
"Best" badge — a COACH ONLY comment on RoundsTable warns against
re-merging the two row presentations; extract a shared date/formatter
helper instead if duplication grows.

Also, two shared-code fixes surfaced while reworking this screen:

  * Zero-rounds EmptyState carried its own filled primary button
    ("Log your first round") alongside the header's own primary ("New
    round"), for both personas. Demoted the empty-state button to
    secondary rather than removing it — it's the player's only forward
    path on a truly empty screen, and this is a defect fix, not a
    v3-scope change.

  * The ledger row's two-column split had `xl:divide-x` but nothing
    for the earlier breakpoints where it stacks to one column on
    phone, leaving no hairline between Score scatter and the grouped
    list. Added `divide-y` as the base state, cancelled at `md`.

The coach table is bare per LANGUAGE.md's "one Surface" rule (the
scatter stage is the page's only Surface); the player ledger keeps its
pre-facelift Surface wrapper unchanged.

Verification: eslint clean and vitest 80/80 (7 files) on this screen's
directory, both under the shared machine's vitest lock. Full-project
tsc was withdrawn from implementers this pass per instruction; skipped
here. Deviations and their reasons are recorded in this screen's spec
Result section, including two honestly-unresolved items: no fresh
player-persona capture was obtained today (dev-server crashes, lock
contention, and login races each cost an attempt), and one desktop
capture showed an empty scatter chart once, not reproduced on an
identical phone capture — investigated, not conclusively resolved.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011zGybqwusmeKC2jJhsU9JC
…Focus column

Undoes the `hidden md:block` on the row's intent control from the prior
commit. It was the wrong fix for the phone table-overflow bug: pill,
name, avatar, and actions checked out for reachability, but hiding the
control removed a coach's only way to set intent on a phone entirely —
FairwayPlayerCard (its other render site) no longer renders anywhere
now that the roster is a table, and FairwayPlayerProfile has no intent
control either.

Rebuilt the same way QualifiersTable already does this
(qualifiers-parts.tsx, commit db18ee5): a `md:hidden` stacked `<ul>`
row (name + year badge, trend, a mono avg/rounds/SG meta line, then the
intent control and actions menu each with a real, untruncated touch
target) alongside the existing dense table wrapped `hidden md:block`.
Both branches stay in the DOM with CSS choosing, so nothing reads a
breakpoint at runtime. The table only renders at `md` and up now, so
the phone-only "All-time" header span and its `aria-label` workaround
come back out — "Avg all-time" is always plain text with room to
spare there.

Also records the lead's decision to decline the stage's Focus column
outright (duplicates the table/ledger, costs the rounds strip real
width, answers a different question than the stage asks) in
roster.v3.md's Result section, replacing the earlier "blocked pending
lead" framing — it was reviewed and declined, not left unanswered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011zGybqwusmeKC2jJhsU9JC
…abel

Read at 768, 1024, 1280 and 1440, this screen failed at exactly 1024. The
three ledger columns and the stage's two-column split both engaged at a
width that cannot hold them: "SG: Approach" broke mid-word to "Appro /
ach", the leak categories truncated to "Co…" and "Sh…", and the
instrument was squeezed into roughly 290px. The stage split, the ledger
grid, the readouts column and the table's Drive and Approach columns move
from `lg:` to `xl:`, so 1024 gets one full-width column and the splits
arrive at 1280 with room to stand. That also retired the "Fiel…"
truncation in the third ledger column without touching StandingBars: at
an xl third it prints "Field Avg" in full.

The par row had the same disease one level down. "Par 4" in column one
truncated to "PA…" at 768 and "P…" at 1024, because a column is an
eighteenth of the width. The word is now a row label in the instrument's
right lane beside "Hole", and the columns carry bare numerals the way a
scorecard prints them. The lane is a real flex track mirroring the column
stack, so each label aligns with its row by structure rather than by an
offset, and it renders only when it has something to hold. The season
view uses it for "Date" and "To par".

tsc clean, eslint clean, 214 tests passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011zGybqwusmeKC2jJhsU9JC
…the sweep

The dead-code sweep deleted files the navigation docs still name, and it
deleted enough of them to move two generated counts. Neither shows up in
a test run; both fail the branch.

DOCS THE SWEEP INVALIDATED

  * `memory/projects/golfhelm.md` — regenerated. The server-action count
    was 205 and is 196. Its hand-written hook table also still listed
    `use-auto-save-round` and `use-offline-sync`, both long gone, under a
    heading that claimed twelve hooks; that is now ten and accurate.
  * `memory/features/admin-platform.md` — `admin-people-data.ts` and
    `admin-bi-data.ts` went with the unreachable admin pages that called
    them, so the feature doc no longer lists them.
  * `memory/features/shot-tracking.md` — the line said round persistence
    was `use-offline-sync.ts`. It was already a correction of an earlier
    dead path, and the correction is now dead too. It names
    `src/lib/offline/sync-engine.ts`, which is what actually runs.
  * `docs/generated/contracts/admin_platform.md`, `WORLD_MODEL.*` and
    `retrieval-bench.json` — regenerated from those sources.

  Result: `docs:path-drift` 15 unresolved → 0, and `docs:dead-refs` 22 →
  4, which is the baseline exactly. The 4 that remain are the selfheal
  and admin-page references that predate this branch.

LINT

  * `capture-golf-facelift.mjs` — eight `no-undef` errors, all inside
    `page.evaluate` callbacks, whose bodies are serialised and run in
    the browser. The globals are real there and absent here. Declared by
    name rather than widening the file's whole env to `browser`, which
    would also silence a genuine mistake in the Node half.
  * `Menu.test.tsx`, `Tooltip.test.tsx`, `boards.test.tsx` — twelve
    `helm/no-raw-button` warnings against a baseline of zero. The raw
    `<button>` in each is a FIXTURE: these cases assert that the wrapper
    accepts an arbitrary element and does not reach into it. Swapping in
    the design-system `<Button>` would test that component's markup
    instead of this one's contract. Disabled with that reason; the rule
    stands everywhere a user can actually see the button.
  * `.supabase-error-baseline.json` — 1030 → 1006. The branch paid down
    24 unchecked reads and the audit refuses to leave that gap open,
    because an unlocked paydown is how #1326 got silently reverted.

Not fixed here: `helm/no-empty-collection-on-error` is 48 against a
baseline of 47. The new site is `getRoundReviewTrend`, which answers a
failed read with `[]` — the same value that means "this player has no
scored rounds." That is a real defect and the fix ends on the round
review sheet, so it is assigned to the agent holding that file rather
than papered over with a disable that would have to claim empty is
right here. It isn't.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011zGybqwusmeKC2jJhsU9JC
`getRoundReviewTrend` returned `[]` from its `if (error)` branch and its
`catch`, which is the same value that means "this player has no scored
rounds". A coach shown a blank season trend concludes the player has no
history, and nothing on the page contradicts that.

The two ERROR paths now return `null`. The three DENIAL paths keep `[]`,
because a malformed id, a viewer without access and a round that is not
this player's all honestly mean "nothing to plot for you". The doc
comment, which promised `[]` on any failure, now says which value means
which.

The page turns `null` into its own `trendUnavailable` state rather than
folding it into the empty case, and an unexpected throw sets it too and
clears the rows, so one player's season can never sit under another
player's name. The stage gets a fourth branch: "Season trend unavailable"
in the same quiet register as the other absences on this sheet, with the
caption dropping its promise of a trajectory it could not read. Not an
empty chart, which would be the lie, and not an error banner, which would
make a secondary absence the loudest thing on the page.

eslint clean on the four files, 207 tests passing across the review page
and round review scopes, including a new case for the null path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011zGybqwusmeKC2jJhsU9JC
The facelift specs added 867 violations against a baseline of 18711, all
of them in `docs/design/fairway-facelift/`. Nothing here changes what a
spec says.

  * 592 were MD013 line-length. These are prose paragraphs, wide ASCII
    layout diagrams and inline Tailwind class strings; a hard 80-column
    wrap makes an ASCII diagram unreadable rather than tidier. One
    `.markdownlint.jsonc` at the root of the spec tree turns MD013 off
    for it and nothing else, which is what AGENTS.md already declares
    for itself on its own first line. Every other rule still applies —
    that is the point of scoping it to one rule in one directory rather
    than updating the baseline.

  * 226 were mechanical and are now fixed rather than excused:
    blanks around headings, lists and fences, list indentation, ordered
    prefixes, trailing newline, bullet style, and compact table pipes.

  * Six `.v2.md` specs opened with a title and then a second H1 five
    lines below it. The second is a section, so it is now an H2.

  * Nine fenced blocks had no language; they are layout sketches, so
    `text`.

  * One prose line in `home.v2.md` began with "- " mid-sentence and
    parsed as a stray list; reflowed. One `<date>` placeholder in
    `player-development.v2.md` parsed as an HTML tag; it is a
    placeholder, so it is code now.

867 → 33. The 33 that remain are all in `roster-player.v3.md`, which is
uncommitted in another agent's working tree and is theirs to fix; it was
deliberately left out of this pass so the fix would not land on top of
their unfinished Result section.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011zGybqwusmeKC2jJhsU9JC
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant