Erstatt flytende Registrer score-banner med kompakt hjørne-FAB (V0)

Fullbredde-bunnbanneret hadde tre runder med bugs i skjul/vis-ved-
scroll-mekanismen (playerListReached), sist når det ble hengende synlig
over Kommentarer. Bruker avviste en fjerde punktfiks og ba i stedet om
en redesign: en liten, hjørne-forankret FAB som er liten nok til at den
ikke trenger å skjule seg igjen for å unngå å dekke innhold.

Ny frontend/components/compact-score-fab.tsx (V0-eksport, integrert
uendret). round-detail.tsx: playerListReached-mekanismen fjernet i sin
helhet (state + effekt + den sårbare mapExpanded-vakten), erstattet med
CompactScoreFab drevet av eksisterende inFlowButtonVisible. Netto -75
linjer.

Verifisert i full-stack scratch (3 spillere + 6 kommentarer): FAB
forblir synlig og ikke-obstruktiv helt ned til og forbi Kommentarer,
lys+mørk modus, 48px trykkflate. tsc rent.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Erol Haagenrud 2026-08-23 08:34:39 +02:00
parent 9d44e1cadb
commit b45aa5bbe4
4 changed files with 120 additions and 111 deletions

View file

@ -14664,3 +14664,38 @@ Neste steg:
kompakt, hjørne-forankret "Score"-FAB som erstatning, jf.
stående instruks om at nye UI-overflater skal gjennom V0 fremfor
håndkodes direkte. Ingen utrulling av punktfiksen fant sted.
165. **Kompakt "Score"-FAB integrert, erstatter fullbredde-bunnbanneret
helt (V0-eksport, oppfølging av punkt 164), 2026-08-23.** V0-prompt
(`Temp-uploads/v0-prompt-compact-score-fab.md`) levert til bruker,
eksport mottatt (`components/compact-score-fab.tsx`) og integrert
tro mot eksporten -- ny fil `frontend/components/compact-score-fab.tsx`,
uendret fra V0 bortsett fra kommentarer.
**`round-detail.tsx`:** `RegisterScoreButton` (den vanlige, i-flyt-
plasserte knappen) er UENDRET og fortsatt eneste sted "Registrer
score ↓"-teksten/pil-ikonet brukes. Den gamle flytende
fullbredde-banner-JSX-en (`fixed inset-x-0 bottom-0` + gradient-scrim
+ `h-20`-spacer) er fjernet i sin helhet, erstattet med
`<CompactScoreFab visible={!inFlowButtonVisible}
onClick={scrollToPlayerList} />`. Hele `playerListReached`-
mekanismen (state + effekt + den sårbare `mapExpanded`-vakten som
forårsaket punkt 164) er fjernet -- ikke lenger nødvendig, siden en
liten hjørne-FAB ikke trenger å skjule seg igjen for å unngå å dekke
over innhold. `inFlowButtonRef`/`inFlowButtonVisible`/
`scrollToPlayerList`/`playerListRef`/`SCROLL_GAP_PX`/
`getHeaderHeight()` er alle UENDRET og gjenbrukt som før (sistnevnte
to også brukt av `scrollToHolePanel()`, urørt av denne runden).
**Verifisert i full-stack scratch** (samme oppskrift som punkt 164 --
3 spillere + 6 kommentarer for å gi nok scroll-lengde): FAB-en
bekreftet skjult mens i-flyt-knappen er synlig, dukker opp
(fade+skaler, IKKE slide-opp) idet i-flyt-knappen ruller ut av syne,
forblir korrekt synlig helt ned til og forbi Kommentarer UTEN å dekke
over innholdet (bekreftet med skjermbilder, både lys og mørk modus),
og skjules igjen ved scroll tilbake til toppen. Trykkflate målt til
48px høyde (over 44px-gulvet), innholds-bredde (~110px, ikke full
bredde). `tsc --noEmit` rent, ingen konsollfeil. Alle scratch-
ressurser ryddet opp.
**Ikke rullet ut ennå** -- venter på brukerens bekreftelse.

View file

@ -0,0 +1,49 @@
"use client"
import { PencilLine } from "lucide-react"
import { cn } from "@/lib/utils"
export type CompactScoreFabProps = {
/** Controls the fade + scale in/out. Driven from outside (the page's
* existing `inFlowButtonVisible` state) -- this component owns no
* hide/show-on-scroll logic of its own. */
visible: boolean
/** Scrolls the user back to the player list (same action as the in-flow
* button). No navigation, no new page. */
onClick: () => void
}
/**
* Compact, corner-anchored "Score" quick button (FAB).
*
* Replaces the old full-width bottom banner (round-detail.tsx, removed
* 2026-08-23 -- see CHANGELOG). Because it's small and pinned to the
* bottom-right corner, it never needs to hide to avoid covering content --
* so there is no scrim, no reserved bottom spacer, and no slide-up
* animation. It simply fades + scales in place.
*/
export function CompactScoreFab({ visible, onClick }: CompactScoreFabProps) {
return (
<button
type="button"
onClick={onClick}
aria-hidden={!visible}
tabIndex={visible ? 0 : -1}
className={cn(
"fixed bottom-[calc(1.25rem+env(safe-area-inset-bottom))] right-[calc(1.25rem+env(safe-area-inset-right))] z-30",
"inline-flex h-12 items-center gap-2 rounded-full px-5",
"bg-primary text-primary-foreground font-bold",
"shadow-lg shadow-black/20",
"transition-all duration-200 ease-in-out",
"hover:brightness-95 active:scale-[0.98]",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
visible ? "scale-100 opacity-100" : "pointer-events-none scale-90 opacity-0",
)}
>
<PencilLine aria-hidden="true" className="size-5" />
Score
</button>
)
}
export default CompactScoreFab

View file

@ -58,6 +58,7 @@ import {
import { ShotMeasurementEntry } from "@/components/shot/shot-measurement-entry"
import { HoleTargetDistance } from "@/components/hole-target-distance"
import { HoleHistoryDetail } from "@/components/hole-history-detail"
import { CompactScoreFab } from "@/components/compact-score-fab"
import { enqueueWrite, flushQueue, queueCount } from "@/lib/offline-queue"
// --- Types -----------------------------------------------------------------
@ -6315,25 +6316,21 @@ function sumForPlayer(list: ApiHole[] | undefined, numbers: number[]): { strokes
// linjer avhengig av runden), så en fast pixel-gjetning viste seg feil for
// en runde med lengre innhold enn den opprinnelige gjetningen (112px) var
// kalibrert mot (scratch-verifisering 2026-08-20: ekte høyde 141,5px for
// en runde med bane+utslag+dato+klokkeslett samtidig). Brukt av BÅDE
// scroll-hoppet til spillerlisten og `playerListReached`-sjekken under.
// en runde med bane+utslag+dato+klokkeslett samtidig). Brukt av
// scroll-hoppene til spillerlisten/hull-panelet under.
function getHeaderHeight(): number {
return document.querySelector("header")?.getBoundingClientRect().height ?? 112
}
// Pusterom under headeren når spillerlisten scrolles til -- brukt for
// BÅDE selve scroll-målposisjonen og "reached"-terskelen (se begge
// under). Delt konstant med vilje: scroll-hoppet lander bevisst
// `SCROLL_GAP_PX` under headeren (ikke flush), så "reached"-sjekken må
// bruke SAMME avstand, ellers forblir den flytende knappen synlig etter
// at hoppet har landet (funnet i scratch-verifisering 2026-08-20 -- de to
// brukte ulike terskler).
// Pusterom under headeren når det scrolles til spillerlisten/hull-panelet
// -- se `scrollToPlayerList()`/`scrollToHolePanel()` under.
const SCROLL_GAP_PX = 16
// "Registrer score" -- samme knapp, to plasseringer (se PlayerHoleCards):
// i normal side-flyt når banekartet er kollapset, flytende festet nederst
// på skjermen når det er utvidet (V0-eksport, 2026-08-20, brukerønske --
// det utvidede diagrammet kunne dytte knappen langt utenfor synlig område).
// "Registrer score" -- den vanlige, i normal side-flyt-plasserte knappen
// (se PlayerHoleCards). Den kompakte, hjørne-forankrede "Score"-FAB-en som
// dukker opp når DENNE ruller ut av syne er en egen komponent,
// `CompactScoreFab` (redesignet 2026-08-23, se CHANGELOG) -- ikke lenger
// samme knapp flyttet til en flytende posisjon.
function RegisterScoreButton({ onClick }: { onClick: () => void }) {
return (
<button
@ -6415,62 +6412,20 @@ function PlayerHoleCards({
const [mapExpanded, setMapExpanded] = useState(false)
const [distanceAvailable, setDistanceAvailable] = useState(false)
const playerListRef = useRef<HTMLUListElement>(null)
// Flytende "Registrer score" (2026-08-20) skal kun vises mens
// spillerlisten fortsatt er UTENFOR synlig område -- brukerfunn: den
// hang igjen synlig (og dekket delvis over spillerkort 1) selv etter at
// brukeren allerede hadde scrollet forbi/til spillerlisten, der den ikke
// lenger gir mening ("Registrer score ↓" når du allerede ser
// spillerne). "Reached" = listens toppkant har nådd/passert headerens
// faktiske høyde -- forblir sant BÅDE mens listen vises OG etter at
// brukeren har scrollet helt forbi den (listen ligger da over synlig
// område, `top` er negativ). Kun scroll TILBAKE OPP forbi listen gjør
// at knappen skal dukke opp igjen.
//
// Første forsøk brukte IntersectionObserver med en krympet rootMargin
// -- viste seg FEIL i scratch-verifisering: med standard threshold=[0]
// fyrer den kun ved 0%->>0%-kryssinger (element helt inn/ut av roten),
// ikke ved at `boundingClientRect.top` passerer en INTERN linje midt i
// et allerede-overlappende element. Listen ble dermed hengende på
// "ikke nådd" gjennom hele resten av scrollingen forbi grensen. Vanlig
// scroll-lytter med direkte `getBoundingClientRect()`-sjekk gir korrekt,
// kontinuerlig oppdatering.
//
// ANNET funn i scratch, samme runde: en HARDKODET grense (112px, lånt
// fra `scroll-mt-28` andre steder i filen) var selv FEIL -- headeren
// sin faktiske høyde målte 141.5px for denne runden, ikke 112. Headeren
// sitt innhold (rundenavn/dato/klokkeslett) kan pakkes til flere linjer
// avhengig av runden, så høyden er reelt DYNAMISK, ikke en konstant --
// en ny hardkodet gjetning (f.eks. 160px) ville bare flyttet samme
// sårbarhet til en annen runde med enda lenger innhold. Målt LIVE i
// stedet, se `getHeaderHeight()` under.
const [playerListReached, setPlayerListReached] = useState(false)
useEffect(() => {
if (!mapExpanded) return
function check() {
const el = playerListRef.current
if (!el) return
setPlayerListReached(el.getBoundingClientRect().top <= getHeaderHeight() + SCROLL_GAP_PX)
}
check()
window.addEventListener("scroll", check, { passive: true })
window.addEventListener("resize", check)
return () => {
window.removeEventListener("scroll", check)
window.removeEventListener("resize", check)
}
}, [mapExpanded])
// Den flytende knappen ble opprinnelig styrt av `mapExpanded` alene --
// FEIL, funnet av bruker 2026-08-20: den IKKE-utvidede ("compact")
// visningen viser OGSÅ hele hindringslisten (ADR-083, alle hindringer,
// ikke bare nærmeste), som på hull med mange hindringer alene kan bli
// høy nok til å dytte den vanlige, side-flyt-plasserte knappen utenfor
// synlig område -- UTEN at banekartet noensinne ble utvidet. Riktig
// signal er derfor ikke "er kartet utvidet", men "er selve
// side-flyt-knappen faktisk synlig akkurat nå" -- målt direkte, samme
// mønster som `playerListReached` over (ikke IntersectionObserver, av
// samme grunn som der: unngår enhver antakelse om når komponenten
// regnes som "utvidet" eller ikke -- fungerer likt uansett ÅRSAK til at
// knappen er utenfor synlig område).
// Flytende "Score"-hurtigknapp (CompactScoreFab, redesignet 2026-08-23
// -- se CHANGELOG): en liten, hjørne-forankret FAB nederst til høyre,
// IKKE lenger et fullbredde-bunnbanner. Trenger derfor ikke lenger noe
// eget "har brukeren nådd spillerlisten"-signal for å skjule seg igjen
// (den tidligere `playerListReached`-mekanismen, fjernet) -- en liten
// knapp i et hjørne dekker ikke nevneverdig over innhold under, uansett
// hvor langt ned siden brukeren har scrollet. Eneste synlighets-signal
// som gjenstår er `inFlowButtonVisible` under: er selve den vanlige,
// side-flyt-plasserte knappen synlig akkurat nå. Målt direkte med
// `getBoundingClientRect()` + scroll/resize-lytter (ikke
// IntersectionObserver -- viste seg upålitelig i scratch-verifisering
// 2026-08-20: med standard threshold=[0] fyrer den kun ved
// 0%->>0%-kryssinger, ikke ved at `top`/`bottom` passerer en INTERN
// linje midt i et allerede-overlappende element).
const inFlowButtonRef = useRef<HTMLDivElement>(null)
const [inFlowButtonVisible, setInFlowButtonVisible] = useState(true)
useEffect(() => {
@ -6883,47 +6838,17 @@ function PlayerHoleCards({
/>
)}
{/* Flytende "Registrer score" (V0-eksport, 2026-08-20, utløser-
logikk rettet samme dag) -- vises når side-flyt-knappen over
IKKE er synlig (uansett ÅRSAK: utvidet banekart ELLER en lang
hindringsliste alene, se `inFlowButtonVisible`-kommentaren over)
OG spillerlisten fortsatt er utenfor synlig område (se
`playerListReached` -- uten denne dekket den delvis over
spillerkort 1 selv etter at brukeren allerede hadde scrollet
dit). `fixed` posisjonerer mot selve viewporten (ingen ancestor
her setter transform/filter, det er trygt), gradient-scrim +
skygge løfter den visuelt over innholdet under,
`env(safe-area-inset-bottom)` unngår at den klemmes helt inntil
kanten telefoner med gest-navigasjon. `pointer-events-none`
selve scrim-sonen når skjult/i overgang, slik at den aldri
blokkerer klikk innhold bak. */}
<div
aria-hidden={inFlowButtonVisible || playerListReached}
className={cn(
"fixed inset-x-0 bottom-0 z-30 px-5 pb-[calc(0.875rem+env(safe-area-inset-bottom))] pt-8 transition-all duration-300 ease-out",
"bg-gradient-to-t from-background via-background/90 to-transparent",
!inFlowButtonVisible && !playerListReached
? "translate-y-0 opacity-100"
: "pointer-events-none translate-y-full opacity-0",
)}
>
<div
className={cn(
"mx-auto w-full max-w-3xl",
!inFlowButtonVisible && !playerListReached ? "pointer-events-auto" : "pointer-events-none",
)}
>
<div className="rounded-2xl shadow-xl shadow-black/25">
<RegisterScoreButton
onClick={scrollToPlayerList}
/>
</div>
</div>
</div>
{/* Gir siste innhold på siden rom til å scrolle klar av den flytende
knappen i stedet for å bli permanent skjult bak den -- samme
betingelse som selve knappen. */}
{!inFlowButtonVisible && !playerListReached && <div aria-hidden="true" className="h-20" />}
{/* Kompakt "Score"-hurtigknapp (CompactScoreFab, redesignet
2026-08-23 -- se CHANGELOG). Erstatter det tidligere fullbredde-
bunnbanneret, som hadde tre runder med skjul/vis-bugs (#141/142,
samt en fjerde runde der den ble hengende synlig over
Kommentarer-seksjonen). En liten, hjørne-forankret FAB trenger
ikke den samme skjul-igjen-mekanismen -- den er liten nok til at
den ikke dekker nevneverdig over innhold, uansett hvor langt ned
siden brukeren har scrollet. Vises alene når
`!inFlowButtonVisible` (den vanlige, side-flyt-plasserte knappen
er utenfor synlig område). */}
<CompactScoreFab visible={!inFlowButtonVisible} onClick={scrollToPlayerList} />
</div>
)
}

File diff suppressed because one or more lines are too long