diff --git a/src/components/fairway/cards-insight/InsightCard.tsx b/src/components/fairway/cards-insight/InsightCard.tsx index dd17c05e4..7a46fbd8f 100644 --- a/src/components/fairway/cards-insight/InsightCard.tsx +++ b/src/components/fairway/cards-insight/InsightCard.tsx @@ -285,7 +285,12 @@ const InsightCardImpl = forwardRef( // action cluster drops BELOW the title row (no horizontal overflow / title // crush at 360px) and sits inline on the right from `sm:` up. isCompact ? 'gap-x-3 gap-y-2 p-4 flex-wrap items-center' : 'gap-4', - !isCompact && (isHero ? 'p-8' : 'p-6'), + // Hero padding steps down on phone (#957) — at a fixed `p-8` + the h2 + // title + body-lg narrative below, this ONE card consumed nearly the + // full first viewport on a phone-width dashboard, reading as the + // "vibe-coded, one long card" tell (Mobile Doctrine rule 11) instead of + // the first section in a composed page. `sm:` and up is unchanged. + !isCompact && (isHero ? 'p-6 sm:p-8' : 'p-6'), // interactivity — visual lift only on the container; the focus ring + the // actual keyboard/click affordance live on the overlay } />, ); - const filtersWrapper = container.querySelector('button')?.parentElement; + const filtersWrapper = container.querySelector('[class*="overflow-x-auto"]'); expect(filtersWrapper).not.toBeNull(); - expect(filtersWrapper!.className).toContain('flex-1'); + expect(filtersWrapper!.className).toContain('sm:grow'); + expect(filtersWrapper!.className).toContain('sm:basis-0'); expect(filtersWrapper!.className).toContain('min-w-0'); }); + + it('below `sm` the row stacks as full-width lines in SOURCE order — no `order` utilities (tab order must match visual order)', () => { + const { container } = render( + } + filters={} + viewToggle={} + />, + ); + // Phone composition (#957 + #959 review): line 1 = search (basis-full), + // line 2 = the filter scroll strip (basis-full), line 3 = view toggle + + // actions (ml-auto). An earlier draft reflowed lines with `order-*`, + // which sent keyboard focus visually backwards on phones — DOM order, + // tab order, and visual order must stay identical, so `order` utilities + // are banned from this row. + const searchWrapper = container.querySelector('input')!.parentElement!; + const strip = container.querySelector('[class*="overflow-x-auto"]')!; + expect(searchWrapper.className).toContain('basis-full'); + expect(strip.className).toContain('basis-full'); + for (const el of [searchWrapper, strip]) { + expect(el.className).not.toMatch(/(?:^|\s)order-/); + } + // The view toggle is mounted exactly ONCE, in the trailing cluster — + // never duplicated into a phone-only slot. + expect(container.querySelectorAll('[data-testid="toggle"]')).toHaveLength(1); + const trailing = container.querySelector('[data-testid="toggle"]')!.parentElement!; + expect(trailing.className).toContain('ml-auto'); + expect(trailing.className).not.toMatch(/(?:^|\s)order-/); + }); }); diff --git a/src/components/fairway/controls/Toolbar.tsx b/src/components/fairway/controls/Toolbar.tsx index fbbd5cc07..415d99d5b 100644 --- a/src/components/fairway/controls/Toolbar.tsx +++ b/src/components/fairway/controls/Toolbar.tsx @@ -59,9 +59,19 @@ import { PopoverPanel } from '../overlays/PopoverPanel'; * Warm cream-glass recipe (self-contained, built from locked --fw-glass-* tokens) * Used ONLY when the row is sticky-stuck or hosting the bulk bar (the two * §4.3-approved chrome slots). Matte at rest never touches this. + * + * `--fw-glass-bg-strong` (88% tint), not the 74% `--fw-glass-bg` — a stuck + * toolbar sits directly over live scrolling body copy (unlike a card-level + * glass surface with more breathing room beneath it), and at 74% + the + * `saturate(190%)` refraction the scrolled text was reading straight through + * the pills, unreadable (#957). `--fw-glass-bg-strong` is the same token the + * modal/command-palette tier already uses for exactly this "must stay legible + * over content" case. No `blur()` is added — that's still the mobile + * scrolling-chrome perf rule (Mobile Doctrine's performance floor); only the + * opacity of the existing tint changes, which is effectively free. * ─────────────────────────────────────────────────────────────────────────── */ const STUCK_GLASS_STYLE: CSSProperties = { - backgroundColor: 'var(--fw-glass-bg)', + backgroundColor: 'var(--fw-glass-bg-strong)', backdropFilter: 'saturate(var(--fw-glass-saturate))', WebkitBackdropFilter: 'saturate(var(--fw-glass-saturate))', borderColor: 'var(--fw-glass-border)', @@ -243,40 +253,62 @@ const ToolbarRoot = forwardRef(function Toolbar( transition={{ duration: reduceMotion ? 0 : 0.16 }} className="flex min-h-[44px] flex-wrap items-center gap-3 px-3 py-2" > - {/* search — grows to absorb slack so the row reads as one quiet - field+controls. Bug #949 #8: below `lg` this still competes - equally (flex-1) with the filters cluster for space, which is - fine on mobile/tablet (the filters strip is a horizontal - scroller there anyway). From `lg` up, pin it to a fixed - comfortable width instead of growing — a search input never - NEEDS more than that, and letting it keep pulling flex-grow - share from `filters` was exactly what squeezed a 3-pill - filter set (Severity/Status/Category) down far enough that - the trailing "Status" pill clipped under the view-toggle - segmented control even at wide desktop widths (>=1280px), - where there was actually plenty of total room. */} + {/* Below `sm` the row re-composes into stacked full-width lines + (#957 — at phone width, search, three filter pills, a + segmented view toggle AND the action buttons cannot share + flex-wrap lines without something clipping mid-word at the + viewport edge, which is exactly what shipped): + line 1 · search, full width + line 2 · the filter-pill scroll strip, full width + line 3 · view toggle + actions, pinned right + Deliberately NO `order` utilities: source order already reads + top-to-bottom/left-to-right at every width, so DOM order, + tab order, and visual order stay identical (a #959-review + finding — an earlier draft reordered lines with `order-*`, + which sent keyboard focus visually backwards on phones). + From `sm` up the basis overrides reset and the original + single-line composition is byte-identical. + + search — grows to absorb slack so the row reads as one quiet + field+controls. From `sm` up: Bug #949 #8 — below `lg` it + competes equally (flex-1) with the filters cluster for space + (fine on tablet, the filters strip scrolls there too). From + `lg` up, pin it to a fixed comfortable width instead of + growing — a search input never NEEDS more than that, and + letting it keep pulling flex-grow share from `filters` was + exactly what squeezed a 3-pill filter set (Severity/Status/ + Category) down far enough that the trailing "Status" pill + clipped under the view-toggle segmented control even at wide + desktop widths (>=1280px), where there was actually plenty of + total room. */} {search ? ( -
{search}
+
+ {search} +
) : null} {/* filters — horizontally scrollable so a long set never breaks - the row. From `lg` up it's now the ONLY flex-1 item on this - line (search stopped competing for the same growth share - above), so it claims all the room left over from search + - the trailing view-toggle/primary-action cluster — the 3-pill - set fits without ever needing its scroll fallback at desktop - widths. */} + the row. Below `sm` it is its own full-width line, so the + scroller gets the whole viewport to work with. From `lg` up + it's the ONLY flex-1 item on its line (search stopped + competing for the same growth share above), so it claims all + the room left over from search + the trailing cluster — the + 3-pill set fits without ever needing its scroll fallback at + desktop widths. */} {filters ? (
{filters}
) : null} - {/* trailing cluster — view toggle + primary action, always far-right */} + {/* trailing cluster — view toggle + primary action. Far-right on + its shared desktop line; the right-pinned last line on phone + (search and filters each took a full line above, so ml-auto + starts this cluster on a fresh line and pushes it right). */} {(viewToggle || primaryAction) && (
{viewToggle} diff --git a/src/components/fairway/overlays/ModalShell.tsx b/src/components/fairway/overlays/ModalShell.tsx index 916746c5b..24b3a5134 100644 --- a/src/components/fairway/overlays/ModalShell.tsx +++ b/src/components/fairway/overlays/ModalShell.tsx @@ -275,7 +275,17 @@ const ModalBody = React.forwardRef< ref={ref} data-slot="modal-shell-body" className={cn( - 'flex-1 overflow-y-auto px-6 py-2 font-fw-sans text-body text-text-secondary', + // `flex-auto min-h-0`, NOT `flex-1`: the panel column is `h-fit` + // (height: fit-content), and iOS Safari resolves flex-1's percentage + // basis (0%) against that as a literal 0 — the body rendered ~0px tall, + // its content clipped to a sliver, on iPhone while desktop engines + // sized it from content (owner report 2026-07-18: the aspect drill-down + // showed title + a clipped chip strip + Done and nothing else; Sheet + // bodies — plain auto-height, no h-fit — never collapsed). `flex-auto` + // sizes from content in every engine, then shrinks (min-h-0) into the + // panel's max-h cap where overflow-y-auto takes over — identical layout + // where it already worked, unbroken on iOS. + 'min-h-0 flex-auto overflow-y-auto px-6 py-2 font-fw-sans text-body text-text-secondary', // breathing room when there is no header/footer 'first:pt-6 last:pb-6', className, diff --git a/src/components/fairway/overlays/Sheet.tsx b/src/components/fairway/overlays/Sheet.tsx index 57b4633f9..2662a587c 100644 --- a/src/components/fairway/overlays/Sheet.tsx +++ b/src/components/fairway/overlays/Sheet.tsx @@ -275,7 +275,14 @@ const SheetBody = React.forwardRef< ref={ref} data-slot="sheet-body" className={cn( - 'flex-1 overflow-y-auto px-6 py-2 font-fw-sans text-body text-text-secondary', + // `flex-auto min-h-0`, NOT `flex-1`: defensive parity with + // ModalShell.Body, where iOS Safari resolved flex-1's percentage basis + // (0%) against the panel's intrinsic-keyword height as 0 and collapsed + // the body (full story there). Sheets size with plain auto height and + // haven't shown the collapse, but content-based basis + min-h-0 gives + // the same layout without ever depending on how an engine resolves a + // percentage basis against an indefinite height. + 'min-h-0 flex-auto overflow-y-auto px-6 py-2 font-fw-sans text-body text-text-secondary', // When the body is the last child it owns the bottom edge → keep its // content clear of the iOS home indicator. 'first:pt-6 last:pb-[max(1.5rem,env(safe-area-inset-bottom))]', diff --git a/src/components/fairway/pages/coachhelm/FairwayAspectDrillDown.tsx b/src/components/fairway/pages/coachhelm/FairwayAspectDrillDown.tsx index 262a51700..8e530a49b 100644 --- a/src/components/fairway/pages/coachhelm/FairwayAspectDrillDown.tsx +++ b/src/components/fairway/pages/coachhelm/FairwayAspectDrillDown.tsx @@ -30,7 +30,7 @@ import * as React from 'react'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { cn } from '@/lib/utils'; -import { ModalShell } from '@/components/fairway/overlays/ModalShell'; +import { Sheet } from '@/components/fairway/overlays/Sheet'; import { Button } from '@/components/fairway/controls/button'; import { Badge } from '@/components/fairway/controls/badge'; import { Segmented } from '@/components/fairway/controls/segmented'; @@ -125,10 +125,11 @@ export function FairwayAspectDrillDown({ }: FairwayAspectDrillDownProps) { const router = useRouter(); - // Retain the last non-null aspect so the ModalShell stays mounted through its - // exit tween after the parent clears `aspect` on close — AnimatePresence can - // only animate out while still mounted (mirrors FocusAreaModal). `aspectProp` - // drives it (flicker-free open); `lastAspect` only covers the closing frame. + // Retain the last non-null aspect so the Sheet stays mounted through its + // exit tween after the parent clears `aspect` on close — vaul can only + // animate out while still mounted (mirrors FocusAreaModal's pattern). + // `aspectProp` drives it (flicker-free open); `lastAspect` only covers the + // closing frame. const [lastAspect, setLastAspect] = React.useState(aspectProp); React.useEffect(() => { if (aspectProp) setLastAspect(aspectProp); @@ -390,14 +391,29 @@ export function FairwayAspectDrillDown({ const drillById = new Map((plan?.drills ?? []).map((d) => [d.id, d])); return ( - - +
{/* ── Header chips ─────────────────────────────────────────────── */}
@@ -695,14 +711,14 @@ export function FairwayAspectDrillDown({
-
+ - + - -
+ + ); } diff --git a/src/components/fairway/pages/coachhelm/FairwayCoachHelmSignals.tsx b/src/components/fairway/pages/coachhelm/FairwayCoachHelmSignals.tsx index 631158f91..f9b66f760 100644 --- a/src/components/fairway/pages/coachhelm/FairwayCoachHelmSignals.tsx +++ b/src/components/fairway/pages/coachhelm/FairwayCoachHelmSignals.tsx @@ -48,6 +48,7 @@ import { } from 'react'; import { useRouter } from 'next/navigation'; import Link from 'next/link'; +import { cn } from '@/lib/utils'; import { Check, X, Target, ChevronDown, ChevronRight, RefreshCw, SlidersHorizontal, User } from 'lucide-react'; import { CoachHelmShell } from './CoachHelmShell'; @@ -211,6 +212,49 @@ function setsEqual(a: Set, b: Set): boolean { * Filter vocabularies (the ONE coherent system replacing 3 mechanisms) * ─────────────────────────────────────────────────────────────────────────── */ +/** + * The ONE evidence renderer, shared by the feed card and the open-signal + * InsightPanel — the panel previously carried its own copy of this markup and + * silently missed the phone fix (#957 review). Below `sm` it is a stack of + * label⇄value rows (label left, value right): a column grid at ~150px per + * column forced eyebrow labels like "ESTIMATED 3-PUTT RATE (15+ FT)" to wrap + * one word per line. From `sm` up, the original column grid (3-up on the + * feed card, 2-up in the narrower panel) is unchanged. + */ +function EvidenceList({ + items, + columns, +}: { + items: ReadonlyArray<{ label: string; value: string; gloss?: string }>; + columns: 2 | 3; +}) { + return ( +
+ {items.map((e) => ( +
+
+ {e.label} +
+
+ {e.value} + {e.gloss ? ( + {e.gloss} + ) : null} +
+
+ ))} +
+ ); +} + const SEVERITY_OPTIONS: ToolbarFilterOption[] = [ { value: 'critical', label: 'Critical' }, { value: 'high', label: 'High' }, @@ -1372,25 +1416,7 @@ export function FairwayCoachHelmSignals({ const renderCard = useCallback( (row: SignalRow, opts?: { hero?: boolean; compact?: boolean }) => { const evidenceNode = - row.evidence.length > 0 ? ( -
- {row.evidence.map((e) => ( -
-
- {e.label} -
-
- {e.value} - {e.gloss ? ( - - {e.gloss} - - ) : null} -
-
- ))} -
- ) : undefined; + row.evidence.length > 0 ? : undefined; // Multi-select is wired ONLY on the insights/alerts surface, where the // bulk-action bar (Acknowledge/Resolve/Dismiss/Export) exists. Patterns @@ -2072,23 +2098,7 @@ export function FairwayCoachHelmSignals({ meta={openRow.confidenceWord ?? undefined} evidence={ openRow.evidence.length > 0 ? ( -
- {openRow.evidence.map((e) => ( -
-
- {e.label} -
-
- {e.value} - {e.gloss ? ( - - {e.gloss} - - ) : null} -
-
- ))} -
+ ) : undefined } evidenceLabel={openRow.evidence.length > 0 ? 'Why this surfaced' : undefined} diff --git a/src/components/fairway/pages/dashboard/FairwayCoachDashboard.tsx b/src/components/fairway/pages/dashboard/FairwayCoachDashboard.tsx index a39693a49..e1f42b85d 100644 --- a/src/components/fairway/pages/dashboard/FairwayCoachDashboard.tsx +++ b/src/components/fairway/pages/dashboard/FairwayCoachDashboard.tsx @@ -390,8 +390,14 @@ export function FairwayCoachDashboard({ }, ]; + // overflow-x-clip on the page root (not -hidden: clip doesn't create a + // scroll container, so sticky children keep working) — hard guarantee that + // no wide child (a table, an unbroken string, a wide chart) can ever + // stretch the page past the viewport; the owner's phone showed every + // full-width card running past the screen edge when one sibling went wide + // (#957). return ( -
+
{/* ── 1 · MASTHEAD — single h1 + promoted action cluster ─────────────── */} { const isUrgent = item.priority === 'high' || item.priority === 'urgent'; return ( -
  • +
  • + {/* `overflow-hidden` is the hard backstop for the title's `truncate` + below — without it, a long single-line title can bleed past this + row's own rounded edge instead of ellipsizing at it (#957). */} {item.overdue ? ( diff --git a/src/components/fairway/pages/dashboard/FairwayPlayerDashboard.tsx b/src/components/fairway/pages/dashboard/FairwayPlayerDashboard.tsx index fac147a2b..7102ddaed 100644 --- a/src/components/fairway/pages/dashboard/FairwayPlayerDashboard.tsx +++ b/src/components/fairway/pages/dashboard/FairwayPlayerDashboard.tsx @@ -299,7 +299,10 @@ export function FairwayPlayerDashboard({ data, enhancedData, hubData }: FairwayP return (
    -
    + {/* overflow-x-clip: same backstop as the coach dashboard root (clip, not + hidden — no scroll container, sticky keeps working) so no wide child + can stretch every full-width card past the phone's viewport (#957). */} +
    {/* ── ViewHeader: single h1, persistent New Round action ───────────── */}