From c5976c875f78fdcd697add40ddd228f5d415d15d Mon Sep 17 00:00:00 2001 From: Fable Integrator Date: Fri, 10 Jul 2026 11:17:48 -0400 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20Bridge=2012-tab=20mobile=20sweep=20?= =?UTF-8?q?=E2=80=94=20every=20/admin=20tab=20hand-composed=20at=20390px?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner report (prod, iPhone): Users & Teams and Work log panned sideways with content bleeding through the bottom nav. Root causes were class-wide, so every Bridge tab was audited and fixed against docs/MOBILE_DOCTRINE.md (one fixer per tab, each adversarially verified): - Rule 8 card rows below md replace min-w tables: users roster (760px), teams/[id] roster, jobs cron board + integrity grid, deploys list, ben-leah issue table; TeamHealthTable now owns its own card/table split (golf + baseball pages heal automatically, baseball's local duplicate removed). - Page-level width-forcers closed: min-w-0 down every flex/grid ancestor chain, base-tier grid-cols-1 on px-minmax grids (work log), break-words on free-form text (PR titles, deploy refs, error dumps). - Monolith KPI cards -> StatStrip rhythm on work/auth/errors/tracer/ ben-leah; StatStrip gains MD_LAST_SPAN_RESET_3 so count=3 + mdColumns>=3 releases the trailing-cell span at md (fixes the empty-cell gap in the 768-1023 range). - Composed phone treatments: work-log timeline gutter slimmed below md, jobs triage-first grouping (attention rows surface, routine rows behind a disclosure, all-clear collapses), health feature-grid collapses green/no-data chips behind a per-group disclosure, deploys show-more cap, ben-leah intake form gets 16px inputs (no iOS zoom), 48px touch targets, and a Rule-5 fixed CTA bar scoped to the form's viewport presence. - Hydration-safe times: PanelAllClear + auth timestamps via LocalTime (server-UTC bake-in bug class). - Inset gains the documented href contract (mirrors Surface) for tap-through rows; Button JSDoc documents the single-child contract. Desktop (md+) is byte-identical everywhere except the documented StatStrip gap change on auth. Gates: tsc, eslint --max-warnings 0, vitest (320 admin + 892 fairway), production build — all green. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01J2E46URHkhCnEQSYDtZLJX --- src/app/admin/_components/FeatureDotGrid.tsx | 129 ++++++- src/app/admin/_components/PanelStates.tsx | 7 +- src/app/admin/_components/TeamHealthTable.tsx | 55 ++- src/app/admin/auth/page.tsx | 17 +- src/app/admin/baseball/page.tsx | 20 +- src/app/admin/ben-leah/BenLeahForm.tsx | 91 ++++- src/app/admin/ben-leah/BenLeahIssueBoard.tsx | 26 +- src/app/admin/ben-leah/BenLeahIssueTable.tsx | 108 ++++-- .../ben-leah/BenLeahIssueWorkflowSelect.tsx | 9 +- src/app/admin/ben-leah/page.tsx | 6 +- .../deploys/_components/ShowMoreList.tsx | 51 +++ src/app/admin/deploys/page.tsx | 276 +++++++++----- src/app/admin/errors/[fingerprint]/page.tsx | 7 +- src/app/admin/errors/page.tsx | 43 ++- .../admin/golf/tracer/TracerPlayerList.tsx | 8 +- src/app/admin/golf/tracer/page.tsx | 35 +- src/app/admin/health/page.tsx | 12 +- src/app/admin/jobs/page.tsx | 338 +++++++++++++----- src/app/admin/teams/[id]/RosterTable.tsx | 152 +++++--- src/app/admin/teams/[id]/page.tsx | 8 +- src/app/admin/users/[id]/page.tsx | 7 +- src/app/admin/users/page.tsx | 206 ++++++++--- src/app/admin/work/WorkTimeline.tsx | 78 ++-- src/app/admin/work/page.tsx | 14 +- src/components/fairway/charts/StatStrip.tsx | 10 + src/components/fairway/controls/button.tsx | 7 + src/components/fairway/surfaces/surface.tsx | 4 + 27 files changed, 1312 insertions(+), 412 deletions(-) create mode 100644 src/app/admin/deploys/_components/ShowMoreList.tsx diff --git a/src/app/admin/_components/FeatureDotGrid.tsx b/src/app/admin/_components/FeatureDotGrid.tsx index 9ba051152..a40d566ed 100644 --- a/src/app/admin/_components/FeatureDotGrid.tsx +++ b/src/app/admin/_components/FeatureDotGrid.tsx @@ -48,6 +48,17 @@ function byStatusRank(a: FeatureHealth, b: FeatureHealth): number { return STATUS_RANK[a.status] - STATUS_RANK[b.status]; } +/** Mirrors the group header's own "N healthy · M no data" phrasing (this + * file, header rollup below) so the below-`md` disclosure CTA never claims + * a flat "healthy" count for a set that also folds in neutral (no + * feature-tagged data) chips. */ +function formatCollapsedSummary(counts: Record): string { + const parts: string[] = []; + if (counts.green > 0) parts.push(`${counts.green} healthy`); + if (counts.neutral > 0) parts.push(`${counts.neutral} no data`); + return parts.join(' · '); +} + function FeatureChip({ feature, selected, @@ -68,19 +79,32 @@ function FeatureChip({ aria-pressed={selected} aria-label={`${feature.label}: ${STATUS_WORD[feature.status]} — ${feature.reason}`} className={cn( - 'h-auto min-h-0 w-full justify-start gap-2 rounded-xl border px-3 py-2 text-left normal-case', + 'h-auto min-h-0 w-full rounded-xl border px-3 py-2 text-left normal-case', richGreen ? 'border-accent-500/35 bg-accent-50/60 hover:bg-accent-50' : 'border-border-subtle bg-surface', )} > - - - {feature.label} - - - {TREND_ARROW[feature.trend]} - {feature.drillIn.warnings24h + feature.topSignatures.reduce((n, s) => n + s.count, 0)} + {/* Button always wraps its `children` in a single inner `` + (src/components/fairway/controls/button.tsx) — passing two + sibling elements there (as this row used to) mixes an inline + box (StatusPill) with a block-level one (a `flex` span), which + the CSS inline-formatting-context rules split onto its own line, + silently breaking the trailing trend/count out from the label AND + making `ml-auto` a no-op (no flex ancestor to push against). One + wrapping row here — passed as Button's ONE child — owns the flex + context itself instead, so identity (icon + label) and the 2 + key stats (trend, incident count) render as one true row, not two + stacked ones — the card-ify treatment doctrine rule 8 asks for. */} + + + + {feature.label} + + + {TREND_ARROW[feature.trend]} + {feature.drillIn.warnings24h + feature.topSignatures.reduce((n, s) => n + s.count, 0)} + ); @@ -99,16 +123,93 @@ function FeatureGroup({ }) { const selected = features.find((f) => f.key === selectedKey) ?? null; const sorted = [...features].sort(byStatusRank); + // Below `md` only: green AND neutral chips collapse behind one disclosure + // per group (Mobile Doctrine rule 3 — BaseballHelm alone registers ~48 + // features; a flat single-column list at 390px would run 15+ + // screen-heights). Neutral (no feature-tagged data yet) must fold too: + // feature tagging only began 2026-07-02, so a group can still be mostly + // neutral this soon after instrumentation — leaving it uncollapsed blows + // the cap even with green hidden. Toggling shows them at every + // breakpoint; `md:block` below always wins at `md` and up, so desktop + // keeps rendering every chip, untouched. + const [showHealthy, setShowHealthy] = useState(false); + + const counts = sorted.reduce( + (acc, f) => { + acc[f.status] += 1; + return acc; + }, + { red: 0, amber: 0, neutral: 0, green: 0 } as Record, + ); + const needsEyes = counts.red + counts.amber; + // Collapsed-by-default set = green AND neutral (REPAIR: neutral used to + // stay in the flat list uncollapsed — feature tagging only began + // 2026-07-02, so a group can be mostly neutral this soon after + // instrumentation and still blow the rule-3 ~3-screen-height cap even + // with green folded away). The header rollup above already reports both + // counts ("N healthy · M no data"), so the CTA mirrors that phrasing. + const collapsedCount = counts.green + counts.neutral; + return (
- - {heading} - + {/* Rollup header: the app label plus an at-a-glance status count, so + a thumb scanning the Health tab knows whether a lane needs eyes + before reading a single chip — the whole point of a daily triage + surface (rule 1's spirit applied to this sub-view). */} +
+ + {heading} + +

+ {needsEyes > 0 ? ( + <> + {counts.red > 0 ? {counts.red} red : null} + {counts.red > 0 && counts.amber > 0 ? ' · ' : null} + {counts.amber > 0 ? {counts.amber} amber : null} + + ) : counts.neutral > 0 ? ( + // Neutral (no feature-tagged data yet) is never relabeled + // "healthy" — same honesty rule the page's own copy states. + + {counts.green} healthy · {counts.neutral} no data + + ) : ( + {counts.green} healthy + )} +

+
- {sorted.map((f) => ( - - ))} + {sorted.map((f) => { + const collapsedOnPhone = f.status !== 'red' && f.status !== 'amber' && !showHealthy; + return ( +
+ +
+ ); + })}
+ {collapsedCount > 0 ? ( + + ) : null} {selected ? : null}
); diff --git a/src/app/admin/_components/PanelStates.tsx b/src/app/admin/_components/PanelStates.tsx index e121235ee..a4ce553cb 100644 --- a/src/app/admin/_components/PanelStates.tsx +++ b/src/app/admin/_components/PanelStates.tsx @@ -1,4 +1,5 @@ import { CheckCircle2, Inbox, CloudOff } from 'lucide-react'; +import { LocalTime } from './LocalTime'; /** All-clear ≠ no-data ≠ fetch-failed. Three distinct states so a silent * dashboard is never mistaken for a healthy system. */ @@ -9,7 +10,11 @@ export function PanelAllClear({ label, checkedAt }: { label: string; checkedAt:

{label}

- checked {new Date(checkedAt).toLocaleTimeString()} + {/* LocalTime, not toLocaleTimeString(): rendered from Server + Components, the raw call bakes in the server's UTC clock — the + viewer sees a wrong-timezone time with no marker (same bug class + LocalTime.tsx documents). */} + checked

); diff --git a/src/app/admin/_components/TeamHealthTable.tsx b/src/app/admin/_components/TeamHealthTable.tsx index c8479a39e..4c9f65e35 100644 --- a/src/app/admin/_components/TeamHealthTable.tsx +++ b/src/app/admin/_components/TeamHealthTable.tsx @@ -24,11 +24,12 @@ export interface TeamHealthEntry { /** * Shared with W9 (baseball) / W10 (users) — sport-agnostic. * - * PHONE-FORMAT RESPONSIVE (owner directive 2026-07-02): `overflow-x-auto` - * scopes the horizontal scroll to the table itself (never the page), and the - * first column stays `sticky` so the team's identity is never scrolled out - * of view on a 375px viewport. Mirrors the cron-board table pattern in - * `/admin/jobs`. + * MOBILE (doctrine Rule 8, 2026-07-10): below `md` each team renders as a + * full-width tap-through card row (identity + roster/last-activity line + + * health pill + honest error count) — the min-w table would otherwise force + * a horizontal scroller on a phone-primary reading surface, which Rule 8 + * bans even when scroll-contained. The table (sticky identity column, + * `overflow-x-auto` scoped to itself) still owns `md` and up, unchanged. * * GREEN CONTRACT (Bridge V2, 2026-07-02): a hairline helm-green rule under * the header, heavy graphite (never green) numerals for roster/error counts, @@ -40,7 +41,46 @@ export interface TeamHealthEntry { */ export function TeamHealthTable({ teams }: { teams: TeamHealthEntry[] }) { return ( -
+ <> + {/* Phone: doctrine-8 card rows, whole row is the link. */} +
+ {teams.map((t) => { + const isLeader = t.health === 'active' && t.errors7d === 0; + return ( + + {/* Dateline rule — replaces the retired border-l-2 leader stripe. */} + {isLeader && } +
+
+

{t.name}

+

+ {t.playerCount} players · last{' '} + {t.lastActivity ? new Date(t.lastActivity).toLocaleDateString() : 'never'} +

+
+ + {t.health} + +
+ {t.errors7d > 0 ? ( +

+ {t.errors7d} errors this week +

+ ) : null} + + ); + })} +
+ + {/* md+: the original sticky-identity table, byte-for-byte. */} +
@@ -88,6 +128,7 @@ export function TeamHealthTable({ teams }: { teams: TeamHealthEntry[] }) { })}
-
+
+ ); } diff --git a/src/app/admin/auth/page.tsx b/src/app/admin/auth/page.tsx index 49790dbdf..cd920aae6 100644 --- a/src/app/admin/auth/page.tsx +++ b/src/app/admin/auth/page.tsx @@ -3,6 +3,7 @@ import { fetchAuthTab, fetchActiveSessions } from '@/lib/admin/data/auth'; import { StatusPill, MetricCard, + StatStrip, TrendChart, InlineNotice, Surface, @@ -13,6 +14,7 @@ import { SportBadge, type BridgeSport } from '../_components/SportBadge'; import { PanelBoundary } from '../_components/PanelBoundary'; import { PanelAllClear, PanelNoData } from '../_components/PanelStates'; import { AutoRefresh } from '../_components/AutoRefresh'; +import { LocalTime } from '../_components/LocalTime'; export const dynamic = 'force-dynamic'; @@ -37,7 +39,14 @@ async function AuthBody() { ) : null} -
+ {/* StatStrip (docs/MOBILE_DOCTRINE.md rule 11): below md this was a bare + `grid` with no column count, so each MetricCard fell back to one + full-bleed row — three stacked monolith cards on a 390px phone. + StatStrip's count=3 phone shape (2-col + a full-width 3rd cell) plus + mdColumns=3 reproduces the original `md:grid-cols-3` desktop recipe + byte-for-byte, matching the same migration already done in + admin/golf, admin/baseball, and admin/page.tsx. */} + -
+ {isLocked && lockedUntilDate ? ( - until {lockedUntilDate.toLocaleTimeString()} + until ) : null} @@ -131,7 +140,7 @@ async function AuthBody() {
- {new Date(row.created_at).toLocaleString()} + ))} diff --git a/src/app/admin/baseball/page.tsx b/src/app/admin/baseball/page.tsx index 4aebce456..c17ef7609 100644 --- a/src/app/admin/baseball/page.tsx +++ b/src/app/admin/baseball/page.tsx @@ -9,7 +9,7 @@ import { Surface, StatStrip, StatTile, StatusPill, TrendChart } from '@/componen import { PanelBoundary } from '../_components/PanelBoundary'; import { PanelNoData } from '../_components/PanelStates'; import { KpiTile } from '../_components/KpiTile'; -import { TeamHealthTable } from '../_components/TeamHealthTable'; +import { TeamHealthTable, type TeamHealthEntry } from '../_components/TeamHealthTable'; import { AutoRefresh } from '../_components/AutoRefresh'; import { FeatureHealthRollup } from '../_components/FeatureHealthRollup'; @@ -181,6 +181,10 @@ async function BaseballBody() { return b.playerCount - a.playerCount; }) .slice(0, 6); + const teamsWithHref: TeamHealthEntry[] = teams.map((team) => ({ + ...team, + href: `/admin/users?team=${team.teamId}`, + })); return (
@@ -190,10 +194,16 @@ async function BaseballBody() {

Baseball command center

-

+ {/* Mobile Doctrine rule 2: eyebrow + long title + paragraph is a + desktop cover treatment. Below `md` the headline condenses to + the smaller text-h3 step and the descriptive paragraph is + dropped entirely (mirrors admin/page.tsx CommandHeader) so + the KPI row below is reachable at 390px without scrolling + past decoration first. */} +

Team-by-team, player-by-player visibility

-

+

Baseball is now a real Helm Bridge operating view: roster posture, quiet players, profile gaps, and production errors are pulled from the same sources as Users, Errors, and Overview.

@@ -307,7 +317,9 @@ async function BaseballBody() { {teams.length === 0 ? ( ) : ( - ({ ...team, href: `/admin/users?team=${team.teamId}` }))} /> + // TeamHealthTable owns its own doctrine-8 split: card rows below + // `md`, the sticky-identity table at `md` and up. + )}
diff --git a/src/app/admin/ben-leah/BenLeahForm.tsx b/src/app/admin/ben-leah/BenLeahForm.tsx index 9d3ab339f..ca86f15c9 100644 --- a/src/app/admin/ben-leah/BenLeahForm.tsx +++ b/src/app/admin/ben-leah/BenLeahForm.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useActionState } from 'react'; +import { useActionState, useEffect, useRef, useState } from 'react'; import { useFormStatus } from 'react-dom'; import { AlertCircle, CheckCircle2, GitPullRequest, ImageUp, Send } from 'lucide-react'; import { Button, StatusPill } from '@/components/fairway'; @@ -12,17 +12,33 @@ import { submitBenLeahFeedback, type BenLeahSubmitState } from './actions'; const initialState: BenLeahSubmitState = { ok: false, message: '' }; +// Mobile Doctrine (no zoom-on-focus): every field sharing this class was +// pinned to a flat `text-sm` (14px) at all breakpoints, which overrode the +// Input/Textarea/NativeSelect components' own iOS-safe `text-base` mobile +// tier (twMerge keeps the LAST conflicting class, and this className is +// always passed last) — every text entry field on the ONE Bridge surface a +// non-technical phone user touches was silently forcing Safari's +// zoom-on-focus. `text-base md:text-sm` restores 16px below `md` and keeps +// the original 14px density at `md`+. `min-h-[48px]` gives NativeSelect the +// same touch target Input already had (its own default has no min-height). const fieldClass = cn( - 'w-full rounded-fw-md border border-border-subtle bg-surface px-3 py-2 text-sm text-warm-900 shadow-flat outline-none', + 'w-full min-h-[48px] rounded-fw-md border border-border-subtle bg-surface px-3 py-2 text-base md:text-sm text-warm-900 shadow-flat outline-none', 'placeholder:text-warm-400 focus:border-accent-500 focus:ring-2 focus:ring-accent-500/20', ); const textareaClass = cn(fieldClass, 'min-h-28 resize-y leading-6'); -function SubmitButton() { +function SubmitButton({ className, fullWidth }: { className?: string; fullWidth?: boolean }) { const { pending } = useFormStatus(); return ( - ); @@ -48,9 +64,45 @@ function Field({ export function BenLeahForm() { const [state, formAction] = useActionState(submitBenLeahFeedback, initialState); + const formRef = useRef(null); + // REPAIR (verified defect, see docs/MOBILE_DOCTRINE.md rule 5): the fixed + // CTA bar below is `position: fixed`, which anchors to the viewport, not + // to this form's own scroll extent (confirmed no transform/filter/ + // will-change/contain ancestor breaks that — admin/template.tsx is + // opacity-only specifically to avoid creating one). Rendering it + // unconditionally meant it stayed on screen for the ENTIRE page scroll — + // including while the user scrolled through the unrelated aside cards and + // the "Issue tracker" panel far below this form, permanently covering the + // tail of that content. An IntersectionObserver on the form scopes the + // bar's screen-time to the form's OWN presence: visible while any part of + // the form is on screen (so it tracks through every field), gone once the + // user has scrolled past the whole form into content this bar has nothing + // to do with. Default state is `true` so SSR output and first client paint + // match (no hydration mismatch) before the observer's first callback. + const [ctaInView, setCtaInView] = useState(true); + + useEffect(() => { + const node = formRef.current; + if (!node || typeof IntersectionObserver === 'undefined') return; + const observer = new IntersectionObserver( + ([entry]) => setCtaInView(entry?.isIntersecting ?? true), + { threshold: 0 }, + ); + observer.observe(node); + return () => observer.disconnect(); + }, []); return ( -
+
@@ -93,7 +145,7 @@ export function BenLeahForm() { - +
- + {/* Desktop keeps the inline CTA; below `md` the primary action moves + to the fixed thumb-zone bar (Rule 5) so it isn't duplicated on + screen. */} +
+ {/* Rule 5 (docs/MOBILE_DOCTRINE.md) — thumb-zone commit: below `md` the + primary action is reachable without scroll-to-save no matter which + field is focused, docked just above Bridge's fixed bottom-tab bar + (56px tall, see FairwayBottomNav, + the iOS home-indicator safe + area). AdminTemplate is opacity-only specifically so `position: + fixed` here stays anchored to the viewport, not a transformed + ancestor (see admin/template.tsx). Gated on `ctaInView` (see the + IntersectionObserver above) so it only occupies the viewport while + this form is actually the thing on screen — it must not keep + floating over the aside cards / issue tracker once the user has + scrolled past the form. */} + {ctaInView ? ( +
+ +
+ ) : null} + {state.message ? (
-
+ {/* StatStrip (Mobile Doctrine rules 2/3/11) — the shared phone-shape + primitive for KPI rows: a 2x2 grid below `md` instead of 4 stacked + full-width rows, so the KPI count doesn't eat the scroll budget + before the issue list is reached. REPAIR (verified defect): desktop + is NOT byte-identical to the prior hand-rolled `grid gap-3 + sm:grid-cols-2 lg:grid-cols-4` recipe — column count at `sm`/`lg` + is unchanged, but StatStrip's own `sm:gap-x-8 sm:gap-y-7` (32px/ + 28px) unconditionally replaces the old flat `gap-3` (12px) at every + breakpoint from `sm` up (src/components/fairway/charts/ + StatStrip.tsx:248, grid branch — this call has count=4, so it never + hits the rail branch's own `sm:gap-x-8 sm:gap-y-7` at line 144). + That gap widening is accepted here as a shared-primitive tradeoff — + StatStrip.tsx is out of this packet's edit scope — not asserted as + unchanged. */} + {( [ ['Open / in progress', openCount, 'Needs attention'], @@ -127,7 +141,7 @@ export async function BenLeahIssueBoard() {

{caption}

))} - + {productionReadyAt ? (

@@ -151,6 +165,12 @@ export async function BenLeahIssueBoard() { )} + {/* Stays 1-up below `sm`: the longest legend label ("Fixed · pending + deploy") is a `whitespace-nowrap` StatusPill sharing its row with a + count — at a 390px viewport a 2-up grid leaves that pill ~145px of + content width for a label that needs ~160px, which would spill out + of the sunken-well card (defect class 1: non-shrinkable chip in a + too-narrow row). Full-width rows are the safe compaction here. */}

{(Object.keys(TRACK_META) as BenLeahTrackStatus[]).map((status) => (
diff --git a/src/app/admin/ben-leah/BenLeahIssueTable.tsx b/src/app/admin/ben-leah/BenLeahIssueTable.tsx index 3662dce12..761768be0 100644 --- a/src/app/admin/ben-leah/BenLeahIssueTable.tsx +++ b/src/app/admin/ben-leah/BenLeahIssueTable.tsx @@ -73,28 +73,94 @@ function IssueRow({ issue }: { issue: BenLeahTrackedIssue }) { ); } -export function BenLeahIssueTable({ issues }: { issues: BenLeahTrackedIssue[] }) { +// Mobile Doctrine rule 8: a `min-w-[980px]` table inside `overflow-x-auto` +// is a horizontal-scroll anti-pattern on a phone-primary reading surface — +// it just relocates the page-overflow problem inside a box instead of +// solving it. Below `md` this renders full-width cards instead: identity +// (issue # + title, wrapped not truncated) + 2-3 key stats (derived status, +// type/priority/category) + the workflow control + a tap-through link to +// GitHub. The desktop table is untouched at `md`+. +function IssueCard({ issue }: { issue: BenLeahTrackedIssue }) { + const meta = TRACK_META[issue.trackStatus]; + return ( -
- - - - - - - - - - - - - - - {issues.map((issue) => ( - - ))} - -
IssueDerived statusSet workflowTypePriorityCategoryUpdatedClosed
+
+
+
+ + #{issue.number} + + + {/* break-words, not truncate — Mobile Doctrine's "text clipped + mid-word" defect class. A long issue title should wrap, never + cut off inside a card whose width is the whole viewport. */} +

{issue.displayTitle}

+
+ + {meta.label} + +
+ +
+ {issue.kind ? KIND_LABEL[issue.kind] ?? issue.kind : '—'} + · + {issue.priority ?? '—'} + · + {issue.category ?? '—'} +
+ +
+ Updated {formatWhen(issue.updated_at)} + {issue.closed_at ? Closed {formatWhen(issue.closed_at)} : null} +
+ +
+ + Set workflow + +
+ +
+
); } + +export function BenLeahIssueTable({ issues }: { issues: BenLeahTrackedIssue[] }) { + return ( + <> +
+ {issues.map((issue) => ( + + ))} +
+ +
+ + + + + + + + + + + + + + + {issues.map((issue) => ( + + ))} + +
IssueDerived statusSet workflowTypePriorityCategoryUpdatedClosed
+
+ + ); +} diff --git a/src/app/admin/ben-leah/BenLeahIssueWorkflowSelect.tsx b/src/app/admin/ben-leah/BenLeahIssueWorkflowSelect.tsx index 3479d03ea..9c7dfb5dd 100644 --- a/src/app/admin/ben-leah/BenLeahIssueWorkflowSelect.tsx +++ b/src/app/admin/ben-leah/BenLeahIssueWorkflowSelect.tsx @@ -18,8 +18,13 @@ const WORKFLOW_OPTIONS: Array<{ value: BenLeahWorkflowSelection; label: string } { value: 'wont_fix', label: "Won't fix" }, ]; +// Below `md` this control now surfaces inside the mobile issue card (see +// BenLeahIssueTable's card-ify split, docs/MOBILE_DOCTRINE.md rule 8) — the +// same breakpoint the table/card swap happens at. `text-xs` at every size +// was below iOS Safari's 16px zoom-on-focus floor; `text-base` restores that +// on the card, `md:text-xs` keeps the original dense desktop-table look. const selectClass = cn( - 'min-w-[9.5rem] rounded-fw-md border border-border-subtle bg-surface px-2 py-1 text-xs text-warm-900 shadow-flat outline-none', + 'min-w-[9.5rem] min-h-[44px] md:min-h-0 rounded-fw-md border border-border-subtle bg-surface px-2 py-1 text-base md:text-xs text-warm-900 shadow-flat outline-none', 'focus:border-accent-500 focus:ring-2 focus:ring-accent-500/20 disabled:opacity-60', ); @@ -64,7 +69,7 @@ export function BenLeahIssueWorkflowSelect({ issue }: { issue: BenLeahTrackedIss ))} - {error ?

{error}

: null} + {error ?

{error}

: null}
); } diff --git a/src/app/admin/ben-leah/page.tsx b/src/app/admin/ben-leah/page.tsx index d4f45ba6d..80dba6774 100644 --- a/src/app/admin/ben-leah/page.tsx +++ b/src/app/admin/ben-leah/page.tsx @@ -20,7 +20,11 @@ export default async function BenLeahPage() {

Submission desk

-

+ {/* Rule 2 (docs/MOBILE_DOCTRINE.md) — an eyebrow + title + full + sentence is a desktop cover treatment; below `md` it condenses + to one line so the intake form (the actual above-fold action) + isn't pushed down by masthead copy. */} +

Capture changes, bugs, additions, screenshots, and source signals as GitHub issues without losing context.

diff --git a/src/app/admin/deploys/_components/ShowMoreList.tsx b/src/app/admin/deploys/_components/ShowMoreList.tsx new file mode 100644 index 000000000..d58c1c92a --- /dev/null +++ b/src/app/admin/deploys/_components/ShowMoreList.tsx @@ -0,0 +1,51 @@ +'use client'; + +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; + +/** + * Mobile "collapsed-by-default" section (Mobile Doctrine rule 3 — cap the + * scroll, don't dump every row). Row markup + data mapping stay + * server-rendered in the caller; this leaf owns ONLY the expanded boolean, so + * the deploys page itself never needs `'use client'`. + * + * `initial` and `more` are pre-rendered lists of `
  • ` elements (or + * fragments thereof) sharing ONE `
      ` — never two adjacent lists — so the + * `divide-y`/`space-y` rhythm the caller applies to the `
        ` stays + * unbroken across the expand boundary. + */ +export function ShowMoreList({ + initial, + more, + moreCount, + itemLabel = 'item', + listClassName, +}: { + initial: React.ReactNode; + more: React.ReactNode; + moreCount: number; + itemLabel?: string; + listClassName?: string; +}) { + const [expanded, setExpanded] = useState(false); + + return ( +
        +
          + {initial} + {expanded ? more : null} +
        + {moreCount > 0 ? ( + + ) : null} +
        + ); +} diff --git a/src/app/admin/deploys/page.tsx b/src/app/admin/deploys/page.tsx index 346b712f4..b4b762a95 100644 --- a/src/app/admin/deploys/page.tsx +++ b/src/app/admin/deploys/page.tsx @@ -3,6 +3,7 @@ import { fetchVercelDeployments, fetchVercelWebInsights, formatDeployAge, + type VercelDeployment, type VercelDeployState, } from '@/lib/admin/vercel-api'; import { fetchSentryReleaseHealth } from '@/lib/admin/sentry-api'; @@ -10,7 +11,13 @@ import { githubIssuesRepo } from '@/lib/admin/github-issues-config'; import { PanelBoundary } from '../_components/PanelBoundary'; import { PanelNoData, PanelStale } from '../_components/PanelStates'; import { AutoRefresh } from '../_components/AutoRefresh'; -import { Surface, StatTile, StatusPill, type FwStatusTone } from '@/components/fairway'; +import { Surface, Inset, StatTile, StatusPill, type FwStatusTone } from '@/components/fairway'; +import { ShowMoreList } from './_components/ShowMoreList'; + +/** Phone card list (below `md`) shows this many deploys before "Show more" — + * keeps the default view inside the ~3-screen-height scroll budget (Mobile + * Doctrine rule 3) even though the desktop table renders all 20. */ +const MOBILE_VISIBLE_DEPLOYS = 5; export const dynamic = 'force-dynamic'; @@ -63,7 +70,11 @@ function CurrentBuildCard() { return (

        This running build

        -

        + {/* break-words: VERCEL_GIT_COMMIT_REF is an unbounded branch name with no + spaces (only hyphens/slashes) — without this, a long branch forces + the line past the card edge and, since nothing here scopes overflow, + drags the whole page into a horizontal pan (doctrine rule 1/3). */} +

        {sha ? sha.slice(0, 7) : 'local'} · {ref ?? 'working tree'} · {env}

        {message ?

        {message}

        : null} @@ -73,11 +84,14 @@ function CurrentBuildCard() { } /** - * PHONE-FORMAT RESPONSIVE (owner directive 2026-07-02): `overflow-x-auto` - * scopes the horizontal scroll to the table itself (never the page), and the - * first column stays `sticky` so the deploy's identity is never scrolled out - * of view on a 375px viewport. Mirrors the cron-board table pattern in - * `/admin/jobs`. + * Below `md`: doctrine-8 cards — identity (sha + branch) up top, state pill + * trailing, the message/URL "tap-through" underneath, then target/age + a + * Sentry deep-link. `md` and up: the original dense table, sticky first + * column, `overflow-x-auto` scoped to the table only. A `min-w-[…] table + * inside overflow-x-auto is the RIGHT call on md+ (a mouse/trackpad surface + * with room to spare) but is explicitly NOT the doctrine treatment on a + * phone-primary reading surface (rule 8) — hence the fork below rather than + * relying on horizontal scroll for phones. */ async function DeploymentsTable() { const deploys = await fetchVercelDeployments(20); @@ -101,84 +115,173 @@ async function DeploymentsTable() { ); } + const rows = deploys.data; + const visibleRows = rows.slice(0, MOBILE_VISIBLE_DEPLOYS); + const restRows = rows.slice(MOBILE_VISIBLE_DEPLOYS); + return ( -
        - - - - - - - - - - - - - {deploys.data.map((d) => { - const sentryHref = sentryReleaseHref(d.commitSha); - const commitSha = d.commitSha; - const deployHref = d.url ? `https://${d.url}` : null; - return ( - - - - - - - - - ); - })} - -
        CommitBranchStateTargetAgeSentry
        - {commitSha ? ( - - {commitSha.slice(0, 7)} - - ) : ( -

        {d.uid.slice(0, 7)}

        - )} - {deployHref ? ( - - {d.commitMessage ?? d.url} - - ) : ( -

        - {d.commitMessage ?? d.url} -

        - )} -
        {d.commitRef ?? '—'} - - {d.state} - - {d.target ?? 'preview'} - {formatDeployAge(d.createdAt)} - - {sentryHref ? ( - - issues → - - ) : ( - - )} -
        -
        + <> + {/* Phone cards (below md) */} +
        + ( + + ))} + more={restRows.map((d) => ( + + ))} + /> +
        + + {/* Desktop table (md and up) */} +
        + + + + + + + + + + + + + {rows.map((d) => { + const sentryHref = sentryReleaseHref(d.commitSha); + const commitSha = d.commitSha; + const deployHref = d.url ? `https://${d.url}` : null; + return ( + + + + + + + + + ); + })} + +
        CommitBranchStateTargetAgeSentry
        + {commitSha ? ( + + {commitSha.slice(0, 7)} + + ) : ( +

        {d.uid.slice(0, 7)}

        + )} + {deployHref ? ( + + {d.commitMessage ?? d.url} + + ) : ( +

        + {d.commitMessage ?? d.url} +

        + )} +
        {d.commitRef ?? '—'} + + {d.state} + + {d.target ?? 'preview'} + {formatDeployAge(d.createdAt)} + + {sentryHref ? ( + + issues → + + ) : ( + + )} +
        +
        + + ); +} + +/** + * One phone-card row: identity (commit sha + branch, tabular/truncated — + * both are unbounded-length width-forcers) + 2–3 key stats (state, target, + * age) + tap-through (the message/URL line, and the Sentry deep-link). + */ +function DeploymentCard({ d }: { d: VercelDeployment }) { + const sentryHref = sentryReleaseHref(d.commitSha); + const commitSha = d.commitSha; + const deployHref = d.url ? `https://${d.url}` : null; + + return ( +
      • + +
        +
        + {commitSha ? ( + + {commitSha.slice(0, 7)} + + ) : ( +

        {d.uid.slice(0, 7)}

        + )} +

        {d.commitRef ?? '—'}

        +
        + + {d.state} + +
        + + {deployHref ? ( + + {d.commitMessage ?? d.url} + + ) : ( +

        {d.commitMessage ?? d.url}

        + )} + +
        + + {d.target ?? 'preview'} · {formatDeployAge(d.createdAt)} + + {sentryHref ? ( + + issues → + + ) : null} +
        +
        +
      • ); } @@ -226,7 +329,10 @@ async function WebVitals() { ); } return ( -
        + // Below `sm`: doctrine "2 + 1-wide" rhythm for a 3-peer strip (2-col grid, + // 3rd cell spans both) instead of 3 full-width stacked rows. `sm:` and up + // is untouched — still the original flat 3-across row. +
        diff --git a/src/app/admin/errors/[fingerprint]/page.tsx b/src/app/admin/errors/[fingerprint]/page.tsx index f2eb45543..05e8838ad 100644 --- a/src/app/admin/errors/[fingerprint]/page.tsx +++ b/src/app/admin/errors/[fingerprint]/page.tsx @@ -66,7 +66,12 @@ export default async function FingerprintDetailPage({ ) : null} {e.stack_trace ? ( -
        {e.stack_trace}
        + // Contained CODE block, never a page-level pan: w-full + min-w-0 + // keep it from ever donating its long-line width to an ancestor, + // overflow-auto gives it its own horizontal+vertical scroller + // instead (classic min-w offender otherwise — Mobile Doctrine + // rule 8 territory even though this isn't literally a table). +
        {e.stack_trace}
        ) : null} ))} diff --git a/src/app/admin/errors/page.tsx b/src/app/admin/errors/page.tsx index b20063cf5..a194f7e60 100644 --- a/src/app/admin/errors/page.tsx +++ b/src/app/admin/errors/page.tsx @@ -8,7 +8,7 @@ import { buildFilteredIncidentsReport, } from '@/lib/admin/data/errors'; import { FEATURE_REGISTRY } from '@/lib/admin/feature-registry'; -import { StatusPill, Surface, type FwStatusTone } from '@/components/fairway'; +import { StatStrip, StatusPill, Surface, type FwStatusTone } from '@/components/fairway'; import { TriageQueue } from '../_components/TriageQueue'; import { ErrorsOverTime } from '../_components/ErrorsOverTime'; import { KpiTile } from '../_components/KpiTile'; @@ -77,8 +77,12 @@ function hrefWithOverrides(current: URLSearchParams, overrides: Record>['incidents'] }) { - const appIncidents = incidents.filter((incident) => incident.origin === 'app'); +/** Takes the already-filtered app-origin subset — never the raw mixed + * incidents array — so the render guard at the call site and the metrics + * computed in here are provably the same population (see repair-round + * fix below: a Sentry-only incidents array must not produce a "0 / 1 = 0%" + * strip). */ +function ErrorTraceabilityStrip({ appIncidents }: { appIncidents: Awaited>['incidents'] }) { const withFeature = appIncidents.filter((incident) => incident.feature).length; const withRoute = appIncidents.filter((incident) => incident.route).length; const withAction = appIncidents.filter((incident) => incident.actionName).length; @@ -106,7 +110,18 @@ function ErrorTraceabilityStrip({ incidents }: { incidents: Awaited 0 || noisyLooking > 0 ? 'needs mapping' : 'mapped'}
        -
        + {/* Below md: 5 peer stat cells become a contained, edge-bled horizontal + snap-rail (Mobile Doctrine rule 3 — cap the scroll, never stack 5 + full-width rows) via the shared StatStrip primitive. `mdColumns={5}` + pins the desktop shape back to the original single-row 5-col grid + starting exactly at md so md+ is byte-for-byte unchanged. */} + {rows.map(([label, value, caption]) => (

        {label}

        @@ -114,7 +129,7 @@ function ErrorTraceabilityStrip({ incidents }: { incidents: Awaited{caption}

        ))} -
        +

        Goal: every app incident should carry feature, route or action, and identity when auth exists. Unknown does not mean unaffected.

        @@ -141,6 +156,12 @@ export default async function ErrorsPage({ async function Body() { const tab = await fetchErrorsTab(filters); const { counts } = tab; + // Sentry-origin and app-origin incidents are concatenated independently + // by mergeTriage() with no invariant coupling them — tab.incidents can be + // non-empty (legacy Sentry issues) while app incidents are genuinely zero + // for this window/filter set. Compute once here so the traceability strip's + // render guard and its metrics agree on the same population. + const appIncidents = tab.incidents.filter((incident) => incident.origin === 'app'); const showWiderWindowHint = tab.incidents.length === 0 && filters.windowHours < 168 && @@ -201,7 +222,11 @@ export default async function ErrorsPage({ ) : null}
        -
        + {/* Below sm: 2-col + a full-width trailing cell (Mobile Doctrine + rule 2/11 — a compact "2+1" rhythm instead of 3 KPI blocks + stacked full-width). sm+ reverts to the original 3-equal-column + row untouched (desktop stays as-is). */} +

        active groups

        {counts.totalGroups}

        @@ -295,7 +320,11 @@ export default async function ErrorsPage({ />
        - + {/* Rule 3: empty sections never render — an all-zero coverage strip + when there's nothing to trace is noise, not signal. Gated on + appIncidents (not the raw incidents array): Sentry-only incident + sets must not slip past this guard and render a "0 / 1 = 0%" strip. */} + {appIncidents.length > 0 ? : null}

        diff --git a/src/app/admin/golf/tracer/TracerPlayerList.tsx b/src/app/admin/golf/tracer/TracerPlayerList.tsx index b6df1148b..13ab721b5 100644 --- a/src/app/admin/golf/tracer/TracerPlayerList.tsx +++ b/src/app/admin/golf/tracer/TracerPlayerList.tsx @@ -135,7 +135,13 @@ function PlayerRoundsList({ playerId, rounds }: { playerId: string; rounds: Trac className="block h-auto w-full rounded-xl border-0 px-3 py-2.5 text-left text-sm font-normal leading-5" > - + {/* basis-full below `sm` — same identity-gets-its-own-line + treatment as the player row above and TracerIncidentRow's + title: at the Sheet's ~342px phone width the status pill + + issue count + date already eat the row, so without this a + long course name was squeezed to a near-illegible sliver + instead of wrapping the meta below it (mobile audit). */} + {round.course_name || 'Unknown course'} diff --git a/src/app/admin/golf/tracer/page.tsx b/src/app/admin/golf/tracer/page.tsx index a70305b79..6d16e6254 100644 --- a/src/app/admin/golf/tracer/page.tsx +++ b/src/app/admin/golf/tracer/page.tsx @@ -1,6 +1,6 @@ import { requireSuperAdmin } from '@/lib/admin/require-super-admin'; import { bridgeGetTracerData, bridgeGetTracerEnrichedData } from '@/app/admin/actions/golf-tracer'; -import { Surface, InlineNotice, Sparkline } from '@/components/fairway'; +import { Surface, InlineNotice, Sparkline, StatStrip } from '@/components/fairway'; import { PanelBoundary } from '../../_components/PanelBoundary'; import { PanelAllClear } from '../../_components/PanelStates'; import { KpiTile } from '../../_components/KpiTile'; @@ -32,13 +32,17 @@ async function TracerBody() { return (
        -
        - + {/* StatStrip, not a hand-rolled grid — 5 peers on phone is exactly the + "ragged trailing cell" case (rows of 2/2/1) the primitive exists to + avoid (MOBILE_DOCTRINE rules 3 + 11): phone gets ONE snap-rail, + "Stuck rounds" pinned first since it's this page's most actionable + triage signal, desktop keeps the original md:grid-cols-5. */} + 0 ? 'warning' : 'neutral'} + label="Stuck rounds" + value={enriched.stuckRounds.length} + href="/admin/golf/tracer#stuck-rounds" + tone={enriched.stuckRounds.length > 0 ? 'danger' : 'neutral'} goodDirection="down" /> 0 ? 'warning' : 'neutral'} goodDirection="down" /> 0 ? 'danger' : 'neutral'} + label="Warnings 7d" + value={data.errorStats.warnings7d} + href="/admin/errors?sport=golf&severity=warning" goodDirection="down" /> -
        + +

        Daily activity (30d)

        diff --git a/src/app/admin/health/page.tsx b/src/app/admin/health/page.tsx index b0f66c814..133fe8923 100644 --- a/src/app/admin/health/page.tsx +++ b/src/app/admin/health/page.tsx @@ -47,8 +47,16 @@ export default async function FeatureHealthPage() { Feature Health -

        Every GolfHelm, CoachHelm, and BaseballHelm feature, at a glance

        -

        + {/* Mobile Doctrine rule 2: eyebrow + long title + paragraph is a + desktop cover treatment. Below `md` the headline condenses to + text-h3 and the descriptive paragraph is dropped entirely + (mirrors admin/page.tsx CommandHeader and admin/baseball's + masthead) so the feature grid — the actual daily-loop content — + is reachable at 390px without scrolling past decoration first. */} +

        + Every GolfHelm, CoachHelm, and BaseballHelm feature, at a glance +

        +

        Computed from get_feature_health() with 2-window hysteresis — a single blip never flips a dot. Features with no feature-tagged data yet render neutral, never red or fake-green. Baseball client errors are promoted into feature tags before they reach this board. diff --git a/src/app/admin/jobs/page.tsx b/src/app/admin/jobs/page.tsx index 42256c045..9a8171108 100644 --- a/src/app/admin/jobs/page.tsx +++ b/src/app/admin/jobs/page.tsx @@ -1,9 +1,10 @@ import { requireSuperAdmin } from '@/lib/admin/require-super-admin'; import { fetchJobsTab, type CronBoardRow, type IntegrityRow } from '@/lib/admin/data/jobs'; -import { Surface, StatTile, StatusPill, type FwStatusTone } from '@/components/fairway'; +import { Surface, Inset, StatTile, StatusPill, type FwStatusTone } from '@/components/fairway'; import { DatelineRule } from '@/components/ui/card'; +import { cn } from '@/lib/utils'; import { PanelBoundary } from '../_components/PanelBoundary'; -import { PanelNoData } from '../_components/PanelStates'; +import { PanelNoData, PanelAllClear } from '../_components/PanelStates'; import { AutoRefresh } from '../_components/AutoRefresh'; export const dynamic = 'force-dynamic'; @@ -43,56 +44,212 @@ function formatDuration(ms: number | null): string { return `${(ms / 1000).toFixed(1)}s`; } +/** A single label→value line inside a card. Value wraps (never clips — + * MOBILE_DOCTRINE rule 8 "identity + key stats", not truncated text). */ +function StatLine({ label, value }: { label: string; value: React.ReactNode }) { + return ( +

        + {label} + + {value} + +
        + ); +} + +/** + * One cron job as a phone-width card: identity + status up top, three key + * stats below (MOBILE_DOCTRINE rule 8 — no per-job detail route exists, so + * there's no tap-through target). Alarm rows (`overdue`/`failed`) get a + * hairline danger ring so they read as distinct from the routine list even + * without scrolling back up to compare pills. + */ +function CronJobCard({ row }: { row: CronBoardRow }) { + const isAlarm = row.status === 'overdue' || row.status === 'failed'; + return ( + +
        +

        {row.jobType}

        + + {row.status} + +
        +
        + + + +
        + {row.status === 'failed' && row.lastError ? ( +

        + {row.lastError} +

        + ) : null} +
        + ); +} + +/** + * Phone card-ify of the cron board (MOBILE_DOCTRINE rule 8: a `min-w-[###px]` + * table in `overflow-x-auto` is NOT the doctrine treatment on a phone-primary + * surface — it just moves the pan sideways inside a smaller box). Triage + * first (rule 1 + the craft bar's "ops surface, checked from a phone"): jobs + * that actually need attention (`overdue`/`failed`) render as cards + * immediately; everything on schedule rolls into ONE all-clear card (rule 3 + * "empty sections never render — one all-caught-up card") with the routine + * rows tucked behind a collapsed disclosure so 18 registry entries don't + * blow the ~3-screen-height scroll budget. + */ +function CronBoardCards({ rows }: { rows: CronBoardRow[] }) { + const alarmRows = rows.filter((r) => r.status === 'overdue' || r.status === 'failed'); + const restRows = rows.filter((r) => r.status !== 'overdue' && r.status !== 'failed'); + + return ( +
        + {alarmRows.length > 0 ? ( +
        + {alarmRows.map((row) => ( + + ))} +
        + ) : ( + + )} + {restRows.length > 0 ? ( +
        + + {restRows.length} other job{restRows.length === 1 ? '' : 's'} — view schedule + +
        + {restRows.map((row) => ( + + ))} +
        +
        + ) : null} +
        + ); +} + /** - * PHONE-FORMAT RESPONSIVE (owner directive 2026-07-02): every admin table — - * including this cron board and the integrity grid below — must render - * cleanly at ~375px. `overflow-x-auto` scopes the horizontal scroll to the - * table itself (never the page), and the first column stays `sticky` so the - * row's identity is never scrolled out of view. + * PHONE-FORMAT RESPONSIVE: the table stays the `md:`+ presentation + * unchanged. Below `md`, this renders `CronBoardCards` instead — a + * `min-w-[640px]` table scoped by `overflow-x-auto` still forces a sideways + * pan on a 390px phone, which is exactly the treatment MOBILE_DOCTRINE rule + * 8 rules out for a phone-primary reading surface. */ function CronBoardTable({ rows }: { rows: CronBoardRow[] }) { return ( -
        - - - - - - - - - - - - {rows.map((row) => ( - - - - - - + <> +
        +
        JobStatusLast runDurationCadence
        - {row.jobType} - - - {row.status} - - {row.status === 'failed' && row.lastError ? ( -

        - {row.lastError} -

        - ) : null} -
        - {row.lastRunAt ? new Date(row.lastRunAt).toLocaleString() : 'awaiting first run'} - - {formatDuration(row.lastDurationMs)} - {row.cadenceMinutes}m
        + + + + + + + + + + {rows.map((row) => ( + + + + + + + + ))} + +
        JobStatusLast runDurationCadence
        + {row.jobType} + + + {row.status} + + {row.status === 'failed' && row.lastError ? ( +

        + {row.lastError} +

        + ) : null} +
        + {row.lastRunAt ? new Date(row.lastRunAt).toLocaleString() : 'awaiting first run'} + + {formatDuration(row.lastDurationMs)} + {row.cadenceMinutes}m
        +
        +
        + +
        + + ); +} + +/** One integrity check as a phone-width card — mirrors `CronJobCard`'s + * rhythm so the two sections read as one composed page, not two bolted-on + * treatments (MOBILE_DOCTRINE rule 11). */ +function IntegrityCheckCard({ check }: { check: IntegrityRow }) { + const isFail = check.status === 'fail'; + return ( + +
        +

        {check.check}

        + + {check.status} + +
        +
        + + +
        + {isFail && check.sample.length > 0 ? ( +
        + + view sample rows + +
        +            {JSON.stringify(check.sample, null, 2)}
        +          
        +
        + ) : null} +
        + ); +} + +/** Phone card-ify of the integrity grid (rule 8), same alarm-first / rolled- + * up-clean rhythm as `CronBoardCards` — a passing suite collapses into one + * all-clear card instead of N repeated "pass" rows. */ +function IntegrityCards({ checks }: { checks: IntegrityRow[] }) { + const failing = checks.filter((c) => c.status === 'fail'); + const passing = checks.filter((c) => c.status === 'pass'); + + return ( +
        + {failing.length > 0 ? ( +
        + {failing.map((c) => ( + ))} - - +
        + ) : ( + + )} + {passing.length > 0 && failing.length > 0 ? ( +
        + + {passing.length} passing check{passing.length === 1 ? '' : 's'} — view + +
        + {passing.map((c) => ( + + ))} +
        +
        + ) : null}
        ); } @@ -108,49 +265,54 @@ function IntegrityGrid({ checks }: { checks: IntegrityRow[] }) { } return ( -
        - - - - - - - - - - - {checks.map((c) => ( - - - - - + <> +
        +
        CheckStatusOffending rowsLast run
        - {c.check} - - - {c.status} - - - {c.status === 'fail' && c.sample.length > 0 ? ( -
        - - {c.count} — view rows - -
        -                      {JSON.stringify(c.sample, null, 2)}
        -                    
        -
        - ) : ( - c.count - )} -
        - {new Date(c.lastRunAt).toLocaleString()} -
        + + + + + + - ))} - -
        CheckStatusOffending rowsLast run
        -
        + + + {checks.map((c) => ( + + + {c.check} + + + + {c.status} + + + + {c.status === 'fail' && c.sample.length > 0 ? ( +
        + + {c.count} — view rows + +
        +                        {JSON.stringify(c.sample, null, 2)}
        +                      
        +
        + ) : ( + c.count + )} + + + {new Date(c.lastRunAt).toLocaleString()} + + + ))} + + +
        +
        + +
        + ); } diff --git a/src/app/admin/teams/[id]/RosterTable.tsx b/src/app/admin/teams/[id]/RosterTable.tsx index e5569cdc3..fecf51c89 100644 --- a/src/app/admin/teams/[id]/RosterTable.tsx +++ b/src/app/admin/teams/[id]/RosterTable.tsx @@ -1,5 +1,6 @@ import Link from 'next/link'; -import { StatusPill } from '@/components/fairway'; +import { ChevronRight } from 'lucide-react'; +import { StatusPill, Inset } from '@/components/fairway'; import { cn } from '@/lib/utils'; import type { TeamHealth } from '@/lib/admin/data/golf'; @@ -52,66 +53,117 @@ function formatToPar(toPar: number | null): string { * muted (never red-background) treatment for dormant players — red stays * reserved for genuine errors, not a quiet roster. * - * PHONE-FORMAT RESPONSIVE: same sticky-first-column pattern as - * TeamHealthTable (`overflow-x-auto` on the table only, never the page). + * PHONE-FORMAT RESPONSIVE (doctrine rule 8): below `md` this is identity + + * key-stat CARDS, not a min-w table inside overflow-x-auto — a min-w table + * is never the phone treatment on this reading surface. `md:` and up keeps + * the dense table, sticky-first-column pattern shared with TeamHealthTable + * (`overflow-x-auto` on the table only, never the page). */ export function RosterTable({ roster }: { roster: RosterDisplayRow[] }) { const sorted = sortRoster(roster); const maxRounds30d = roster.reduce((max, r) => Math.max(max, r.rounds30d), 0); return ( -
        - - - - - - - - - - - - {sorted.map((r) => { - const isLeader = maxRounds30d > 0 && r.activityStatus === 'active' && r.rounds30d === maxRounds30d; - const isDormant = r.activityStatus === 'dormant'; - return ( - - - - - - - - ); - })} - -
        PlayerJerseyLast roundRounds 30dActivity
        + <> +
          + {sorted.map((r) => { + const isLeader = maxRounds30d > 0 && r.activityStatus === 'active' && r.rounds30d === maxRounds30d; + const isDormant = r.activityStatus === 'dormant'; + return ( +
        • + +
          {/* Dateline rule — replaces the retired border-l-2 leader stripe. */} {isLeader && } - {r.name}

          +

          + {r.jerseyNumber ? `#${r.jerseyNumber}` : 'no #'} ·{' '} + {r.lastRoundScore !== null ? ( + <> + {r.lastRoundScore} ({formatToPar(r.lastRoundToPar)}) + + ) : ( + 'no rounds' )} - > - {r.name} - -

        {r.jerseyNumber ?? '—'} - {r.lastRoundScore !== null ? ( - <> - {r.lastRoundScore}{' '} - ({formatToPar(r.lastRoundToPar)}) - - ) : ( - no rounds - )} - {r.rounds30d} +

        + +
        {r.activityStatus} -
        -
        + + {r.rounds30d} rounds 30d + +

        + + + + ); + })} +
      + +
      + + + + + + + + + + + + {sorted.map((r) => { + const isLeader = maxRounds30d > 0 && r.activityStatus === 'active' && r.rounds30d === maxRounds30d; + const isDormant = r.activityStatus === 'dormant'; + return ( + + + + + + + + ); + })} + +
      PlayerJerseyLast roundRounds 30dActivity
      + {/* Dateline rule — replaces the retired border-l-2 leader stripe. */} + {isLeader && } + + {r.name} + + {r.jerseyNumber ?? '—'} + {r.lastRoundScore !== null ? ( + <> + {r.lastRoundScore}{' '} + ({formatToPar(r.lastRoundToPar)}) + + ) : ( + no rounds + )} + {r.rounds30d} + + {r.activityStatus} + +
      +
      + ); } diff --git a/src/app/admin/teams/[id]/page.tsx b/src/app/admin/teams/[id]/page.tsx index 5f1db7da2..552e7a08f 100644 --- a/src/app/admin/teams/[id]/page.tsx +++ b/src/app/admin/teams/[id]/page.tsx @@ -123,7 +123,13 @@ async function TeamDetailBody({ teamId }: { teamId: string }) {
      -

      {team.name}

      + {/* `min-w-0` on an h1 that's itself a flex item — without it + `truncate`'s text-overflow:ellipsis never engages, since a + flex item's default min-width:auto keeps it at its full + content size. Long team names would otherwise just push + the health pill/grade badge onto their own row instead of + truncating. */} +

      {team.name}

      {health} diff --git a/src/app/admin/users/[id]/page.tsx b/src/app/admin/users/[id]/page.tsx index b7377f44f..0570810b1 100644 --- a/src/app/admin/users/[id]/page.tsx +++ b/src/app/admin/users/[id]/page.tsx @@ -58,7 +58,12 @@ export default async function UserDetailPage({
      -

      {user.email}

      + {/* `min-w-0` on the h1 itself — it's a flex item of THIS inner + row (nested inside the outer `min-w-0 flex-1` header cell), + so without its own min-w-0 the default min-width:auto keeps + it pinned to the email's full un-clipped width, and + `truncate` never engages for a long address. */} +

      {user.email}

      {user.sports.map((s) => ( ))} diff --git a/src/app/admin/users/page.tsx b/src/app/admin/users/page.tsx index 90f331d48..97e9fb4c2 100644 --- a/src/app/admin/users/page.tsx +++ b/src/app/admin/users/page.tsx @@ -1,7 +1,9 @@ import Link from 'next/link'; +import { ChevronRight } from 'lucide-react'; import { requireSuperAdmin } from '@/lib/admin/require-super-admin'; import { fetchUsersTab, type RosterPlayerInsight, type TeamRosterInsight } from '@/lib/admin/data/users'; -import { Surface, StatTile, StatusPill, SearchField, Button, type FwStatusTone } from '@/components/fairway'; +import { Surface, Inset, StatTile, StatStrip, StatusPill, SearchField, Button, type FwStatusTone } from '@/components/fairway'; +import { cn } from '@/lib/utils'; import { PanelBoundary } from '../_components/PanelBoundary'; import { PanelAllClear, PanelNoData } from '../_components/PanelStates'; import { SportBadge } from '../_components/SportBadge'; @@ -61,7 +63,11 @@ function TeamRosterPanel({ team, activeTeamId }: { team: TeamRosterInsight; acti
      - + {/* min-w-0 on the Link — it's a flex item of this row alongside + the badge/pill; without it, truncate never engages for a + long team name (default flex min-width:auto pins it to its + full content width). */} + {team.name} @@ -92,61 +98,135 @@ function TeamRosterPanel({ team, activeTeamId }: { team: TeamRosterInsight; acti
      -
      +
      {topPlayers.length === 0 ? ( ) : ( - - - - - - - - - - - - - - {topPlayers.map((player) => ( - - - - - - - - - - ))} - -
      PlayerRosterActivity 30dLast signalProfileErrorsDetail
      + <> + {/* Phone (doctrine rule 8): identity + key-stat CARDS, not a + min-w table inside overflow-x-auto — that's a phone-scroll + surface, not the doctrine treatment, on a reading surface + this dense. Whole row is the tap-through when a detail page + exists (ActivityFeed's "EVERYTHING CLICKS" convention). */} +
        + {topPlayers.map((player) => { + const meta = [ + player.jerseyNumber ? `#${player.jerseyNumber}` : 'no #', + player.position, + player.detail, + ] + .filter(Boolean) + .join(' · '); + const rowContent = ( +
        +
        +

        {player.name}

        +

        {player.email ?? 'no email'}

        +

        {meta}

        +

        + last signal {shortDate(player.lastActivity ?? player.lastSeen)} +

        +
        +
        + + {player.profileQuality} + +
        + + {player.activity30d} + + +
        + {player.errors7d > 0 ? ( + + {player.errors7d} errors + + ) : null} +
        +
        + ); + return ( +
      • {player.href ? ( - - {player.name} - + + {rowContent} + + ) : ( - {player.name} + {rowContent} )} - {player.email ?? 'no email'} -
      - {player.jerseyNumber ? `#${player.jerseyNumber}` : 'no #'} - {player.position ? ` · ${player.position}` : ''} - -
      - - {player.activity30d} - - -
      -
      - {shortDate(player.lastActivity ?? player.lastSeen)} - - - {player.profileQuality} - - {player.errors7d}{player.detail}
      + + ); + })} +
    + + {/* Tablet/desktop: dense table, contained to its own scroller. */} +
    + + + + + + + + + + + + + + {topPlayers.map((player) => ( + + + + + + + + + + ))} + +
    PlayerRosterActivity 30dLast signalProfileErrorsDetail
    + {player.href ? ( + + {player.name} + + ) : ( + {player.name} + )} + {player.email ?? 'no email'} + + {player.jerseyNumber ? `#${player.jerseyNumber}` : 'no #'} + {player.position ? ` · ${player.position}` : ''} + +
    + + {player.activity30d} + + +
    +
    + {shortDate(player.lastActivity ?? player.lastSeen)} + + + {player.profileQuality} + + {player.errors7d}{player.detail}
    +
    + )} {!isFocused && team.players.length > topPlayers.length ? (

    @@ -201,13 +281,17 @@ function RosterIntelligence({ return (

    -
    + {/* StatStrip, not a hand-rolled grid — 5 peers on phone is exactly the + "ragged trailing cell" case (rows of 2/2/1) the primitive exists to + avoid (MOBILE_DOCTRINE rules 3 + 11); phone gets one snap-rail, + desktop keeps the original md:grid-cols-5. */} + -
    + Roster command map @@ -226,8 +310,8 @@ function RosterIntelligence({ ))}
    -
    -
    +
    +
    {golfTeams} GolfHelm teams {baseballTeams} BaseballHelm teams @@ -264,12 +348,16 @@ function RosterIntelligence({
    + {/* min-w-0 — flex item of this row alongside the + badge; without it truncate never engages for a + long player name (see TeamRosterPanel's + matching fix above). */} {player.href ? ( - + {player.name} ) : ( - {player.name} + {player.name} )}

    @@ -351,12 +439,12 @@ export default async function UsersPage({ ) : null} -

    + -
    + {tab.totalUsersCount > tab.users.length ? (

    Showing the {tab.users.length} most recently seen users (capped view — {tab.totalUsersCount} total match diff --git a/src/app/admin/work/WorkTimeline.tsx b/src/app/admin/work/WorkTimeline.tsx index 53335f9c9..3b4dd7625 100644 --- a/src/app/admin/work/WorkTimeline.tsx +++ b/src/app/admin/work/WorkTimeline.tsx @@ -2,7 +2,7 @@ import Link from 'next/link'; import { ExternalLink, GitPullRequest } from 'lucide-react'; import type { WorkLogEntry } from '@/lib/admin/github-pr-timeline'; import type { WorkArea } from '@/lib/admin/pr-body-parser'; -import { StatusPill, Surface, StatTile, type FwStatusTone } from '@/components/fairway'; +import { StatusPill, Surface, StatTile, StatStrip, type FwStatusTone } from '@/components/fairway'; const AREA_META: Record = { golf: { label: 'GolfHelm', tone: 'success' }, @@ -57,18 +57,21 @@ function TimelineCard({ entry }: { entry: WorkLogEntry }) { 'Add a Partner-readable summary or Git Activity Timeline note to this PR.'; return ( -

    +
    + {/* Timeline rail: a slim always-on line on phone (no marker — the + 32px desktop gutter + 16px circle is desktop chrome, doctrine + rule 7); the full line+marker treatment returns at md. */} - -
    + +
    @@ -83,15 +86,15 @@ function TimelineCard({ entry }: { entry: WorkLogEntry }) { ))}
    -

    +

    - #{entry.number} - {entry.title} + #{entry.number} + {entry.title}

    @@ -102,19 +105,23 @@ function TimelineCard({ entry }: { entry: WorkLogEntry }) {
    -
    -
    +
    +

    Problem

    -

    {problem}

    +

    + {problem} +

    -
    +

    Fix / outcome

    -

    {fix}

    +

    + {fix} +

    {entry.parsed.timelineNote && entry.parsed.timelineNote !== fix ? ( -

    +

    Partner line: {entry.parsed.timelineNote}

    ) : null} @@ -146,8 +153,8 @@ export function WorkTimeline({ .slice(0, 4); return ( -
    -
    +
    +
    GitHub PRs @@ -173,7 +180,15 @@ export function WorkTimeline({
    -
    + {/* Count tiles: StatStrip is the ONE phone-shape primitive for KPI rows + (doctrine rules 2/3/11 — docs/MOBILE_DOCTRINE.md). 3 peers → a 2-col + phone grid with the last cell (Open — the actionable count for a + triage-first ops surface) spanning full width. The heterogeneous + "Top areas" chip cluster is NOT a KPI peer, so it gets its own + full-width card below instead of a cramped 4th grid cell — mixed + row/card rhythm per the craft bar, not a monolith and not a + mis-matched grid cell. */} +
    @@ -183,24 +198,23 @@ export function WorkTimeline({
    -
    + + + {topAreas.length > 0 ? ( +

    Top areas

    - {topAreas.length > 0 ? ( - topAreas.map(([area, count]) => ( - - {AREA_META[area].label} · {count} - - )) - ) : ( - - )} + {topAreas.map(([area, count]) => ( + + {AREA_META[area].label} · {count} + + ))}
    -
    + ) : null} - -

    + +

    Summaries are parsed from your PR template — fill in{' '} Partner-readable summary,{' '} Area, and{' '} diff --git a/src/app/admin/work/page.tsx b/src/app/admin/work/page.tsx index 07995318f..1a3b3f836 100644 --- a/src/app/admin/work/page.tsx +++ b/src/app/admin/work/page.tsx @@ -54,7 +54,7 @@ export default async function WorkLogPage() {

    Shipping timeline

    -

    +

    A partner-readable history of your pull requests — what broke, what shipped, and which Helm surface it touched.

    @@ -69,12 +69,14 @@ export default async function WorkLogPage() {
    -
    - - - +
    +
    + + + +
    -
    @@ -108,7 +109,7 @@ export function TeamHealthTable({ teams }: { teams: TeamHealthEntry[] }) { {t.playerCount} - {t.lastActivity ? new Date(t.lastActivity).toLocaleDateString() : 'never'} + {t.lastActivity ? : 'never'} diff --git a/src/app/admin/ben-leah/BenLeahIssueTable.tsx b/src/app/admin/ben-leah/BenLeahIssueTable.tsx index 761768be0..8c5bf4e5f 100644 --- a/src/app/admin/ben-leah/BenLeahIssueTable.tsx +++ b/src/app/admin/ben-leah/BenLeahIssueTable.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useEffect, useState } from 'react'; import Link from 'next/link'; import { ExternalLink } from 'lucide-react'; import type { BenLeahTrackedIssue } from '@/lib/admin/ben-leah-issue-tracker'; @@ -33,6 +34,26 @@ function formatWhen(iso: string): string { }); } +/** + * This is a Client Component: `formatWhen` resolves against the RUNTIME's + * timezone, so calling it directly in render bakes the server's UTC render + * into the SSR HTML and recomputes to the viewer's local zone on hydration — + * a guaranteed text mismatch (the same bug class LocalTime.tsx documents; + * that shared component doesn't apply here as-is since its variants don't + * carry these custom month/day/hour/minute options). Same fix, replicated: + * a deterministic placeholder until a post-hydration effect swaps in the + * localized string. + */ +function FormattedWhen({ iso }: { iso: string }) { + const [label, setLabel] = useState(null); + + useEffect(() => { + setLabel(formatWhen(iso)); + }, [iso]); + + return {label ?? '—'}; +} + function IssueRow({ issue }: { issue: BenLeahTrackedIssue }) { const meta = TRACK_META[issue.trackStatus]; @@ -65,9 +86,11 @@ function IssueRow({ issue }: { issue: BenLeahTrackedIssue }) { {issue.priority ?? '—'} {issue.category ?? '—'} - {formatWhen(issue.updated_at)} - {issue.closed_at ? formatWhen(issue.closed_at) : '—'} + + + + {issue.closed_at ? : '—'} ); @@ -115,8 +138,14 @@ function IssueCard({ issue }: { issue: BenLeahTrackedIssue }) {
    - Updated {formatWhen(issue.updated_at)} - {issue.closed_at ? Closed {formatWhen(issue.closed_at)} : null} + + Updated + + {issue.closed_at ? ( + + Closed + + ) : null}
    @@ -140,9 +169,15 @@ export function BenLeahIssueTable({ issues }: { issues: BenLeahTrackedIssue[] }) ))}
    -
    + {/* max-h + overflow-auto (not just overflow-x-auto) — the sticky thead + below only pins relative to a scroll container that actually + scrolls internally; matches the `` inside a `max-h-[28rem] overflow-auto` wrapper idiom + already used for a long reviewed-item list in + src/components/baseball/import-center/ImportWizardClient.tsx. */} +
    - + diff --git a/src/app/admin/jobs/page.tsx b/src/app/admin/jobs/page.tsx index 9a8171108..ecc99a468 100644 --- a/src/app/admin/jobs/page.tsx +++ b/src/app/admin/jobs/page.tsx @@ -6,6 +6,7 @@ import { cn } from '@/lib/utils'; import { PanelBoundary } from '../_components/PanelBoundary'; import { PanelNoData, PanelAllClear } from '../_components/PanelStates'; import { AutoRefresh } from '../_components/AutoRefresh'; +import { LocalTime } from '../_components/LocalTime'; export const dynamic = 'force-dynamic'; @@ -75,7 +76,10 @@ function CronJobCard({ row }: { row: CronBoardRow }) {
    - + : 'awaiting first run'} + />
    @@ -116,7 +120,7 @@ function CronBoardCards({ rows }: { rows: CronBoardRow[] }) { )} {restRows.length > 0 ? (
    - + {restRows.length} other job{restRows.length === 1 ? '' : 's'} — view schedule
    @@ -171,7 +175,7 @@ function CronBoardTable({ rows }: { rows: CronBoardRow[] }) { ) : null}
    ))}
    Issue Derived status - {row.lastRunAt ? new Date(row.lastRunAt).toLocaleString() : 'awaiting first run'} + {row.lastRunAt ? : 'awaiting first run'} {formatDuration(row.lastDurationMs)} @@ -204,11 +208,11 @@ function IntegrityCheckCard({ check }: { check: IntegrityRow }) {
    - + } />
    {isFail && check.sample.length > 0 ? (
    - + view sample rows
    @@ -240,7 +244,7 @@ function IntegrityCards({ checks }: { checks: IntegrityRow[] }) {
           )}
           {passing.length > 0 && failing.length > 0 ? (
             
    - + {passing.length} passing check{passing.length === 1 ? '' : 's'} — view
    @@ -302,7 +306,7 @@ function IntegrityGrid({ checks }: { checks: IntegrityRow[] }) { )}
    - {new Date(c.lastRunAt).toLocaleString()} +