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:
parent
9d44e1cadb
commit
b45aa5bbe4
4 changed files with 120 additions and 111 deletions
35
CHANGELOG.md
35
CHANGELOG.md
|
|
@ -14664,3 +14664,38 @@ Neste steg:
|
||||||
kompakt, hjørne-forankret "Score"-FAB som erstatning, jf.
|
kompakt, hjørne-forankret "Score"-FAB som erstatning, jf.
|
||||||
stående instruks om at nye UI-overflater skal gjennom V0 fremfor
|
stående instruks om at nye UI-overflater skal gjennom V0 fremfor
|
||||||
håndkodes direkte. Ingen utrulling av punktfiksen fant sted.
|
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.
|
||||||
|
|
|
||||||
49
frontend/components/compact-score-fab.tsx
Normal file
49
frontend/components/compact-score-fab.tsx
Normal 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
|
||||||
|
|
@ -58,6 +58,7 @@ import {
|
||||||
import { ShotMeasurementEntry } from "@/components/shot/shot-measurement-entry"
|
import { ShotMeasurementEntry } from "@/components/shot/shot-measurement-entry"
|
||||||
import { HoleTargetDistance } from "@/components/hole-target-distance"
|
import { HoleTargetDistance } from "@/components/hole-target-distance"
|
||||||
import { HoleHistoryDetail } from "@/components/hole-history-detail"
|
import { HoleHistoryDetail } from "@/components/hole-history-detail"
|
||||||
|
import { CompactScoreFab } from "@/components/compact-score-fab"
|
||||||
import { enqueueWrite, flushQueue, queueCount } from "@/lib/offline-queue"
|
import { enqueueWrite, flushQueue, queueCount } from "@/lib/offline-queue"
|
||||||
|
|
||||||
// --- Types -----------------------------------------------------------------
|
// --- 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
|
// linjer avhengig av runden), så en fast pixel-gjetning viste seg feil for
|
||||||
// en runde med lengre innhold enn den opprinnelige gjetningen (112px) var
|
// en runde med lengre innhold enn den opprinnelige gjetningen (112px) var
|
||||||
// kalibrert mot (scratch-verifisering 2026-08-20: ekte høyde 141,5px for
|
// 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
|
// en runde med bane+utslag+dato+klokkeslett samtidig). Brukt av
|
||||||
// scroll-hoppet til spillerlisten og `playerListReached`-sjekken under.
|
// scroll-hoppene til spillerlisten/hull-panelet under.
|
||||||
function getHeaderHeight(): number {
|
function getHeaderHeight(): number {
|
||||||
return document.querySelector("header")?.getBoundingClientRect().height ?? 112
|
return document.querySelector("header")?.getBoundingClientRect().height ?? 112
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pusterom under headeren når spillerlisten scrolles til -- brukt for
|
// Pusterom under headeren når det scrolles til spillerlisten/hull-panelet
|
||||||
// BÅDE selve scroll-målposisjonen og "reached"-terskelen (se begge
|
// -- se `scrollToPlayerList()`/`scrollToHolePanel()` under.
|
||||||
// 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).
|
|
||||||
const SCROLL_GAP_PX = 16
|
const SCROLL_GAP_PX = 16
|
||||||
|
|
||||||
// "Registrer score" -- samme knapp, to plasseringer (se PlayerHoleCards):
|
// "Registrer score" -- den vanlige, i normal side-flyt-plasserte knappen
|
||||||
// i normal side-flyt når banekartet er kollapset, flytende festet nederst
|
// (se PlayerHoleCards). Den kompakte, hjørne-forankrede "Score"-FAB-en som
|
||||||
// på skjermen når det er utvidet (V0-eksport, 2026-08-20, brukerønske --
|
// dukker opp når DENNE ruller ut av syne er en egen komponent,
|
||||||
// det utvidede diagrammet kunne dytte knappen langt utenfor synlig område).
|
// `CompactScoreFab` (redesignet 2026-08-23, se CHANGELOG) -- ikke lenger
|
||||||
|
// samme knapp flyttet til en flytende posisjon.
|
||||||
function RegisterScoreButton({ onClick }: { onClick: () => void }) {
|
function RegisterScoreButton({ onClick }: { onClick: () => void }) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|
@ -6415,62 +6412,20 @@ function PlayerHoleCards({
|
||||||
const [mapExpanded, setMapExpanded] = useState(false)
|
const [mapExpanded, setMapExpanded] = useState(false)
|
||||||
const [distanceAvailable, setDistanceAvailable] = useState(false)
|
const [distanceAvailable, setDistanceAvailable] = useState(false)
|
||||||
const playerListRef = useRef<HTMLUListElement>(null)
|
const playerListRef = useRef<HTMLUListElement>(null)
|
||||||
// Flytende "Registrer score" (2026-08-20) skal kun vises mens
|
// Flytende "Score"-hurtigknapp (CompactScoreFab, redesignet 2026-08-23
|
||||||
// spillerlisten fortsatt er UTENFOR synlig område -- brukerfunn: den
|
// -- se CHANGELOG): en liten, hjørne-forankret FAB nederst til høyre,
|
||||||
// hang igjen synlig (og dekket delvis over spillerkort 1) selv etter at
|
// IKKE lenger et fullbredde-bunnbanner. Trenger derfor ikke lenger noe
|
||||||
// brukeren allerede hadde scrollet forbi/til spillerlisten, der den ikke
|
// eget "har brukeren nådd spillerlisten"-signal for å skjule seg igjen
|
||||||
// lenger gir mening ("Registrer score ↓" når du allerede ser
|
// (den tidligere `playerListReached`-mekanismen, fjernet) -- en liten
|
||||||
// spillerne). "Reached" = listens toppkant har nådd/passert headerens
|
// knapp i et hjørne dekker ikke nevneverdig over innhold under, uansett
|
||||||
// faktiske høyde -- forblir sant BÅDE mens listen vises OG etter at
|
// hvor langt ned siden brukeren har scrollet. Eneste synlighets-signal
|
||||||
// brukeren har scrollet helt forbi den (listen ligger da over synlig
|
// som gjenstår er `inFlowButtonVisible` under: er selve den vanlige,
|
||||||
// område, `top` er negativ). Kun scroll TILBAKE OPP forbi listen gjør
|
// side-flyt-plasserte knappen synlig akkurat nå. Målt direkte med
|
||||||
// at knappen skal dukke opp igjen.
|
// `getBoundingClientRect()` + scroll/resize-lytter (ikke
|
||||||
//
|
// IntersectionObserver -- viste seg upålitelig i scratch-verifisering
|
||||||
// Første forsøk brukte IntersectionObserver med en krympet rootMargin
|
// 2026-08-20: med standard threshold=[0] fyrer den kun ved
|
||||||
// -- viste seg FEIL i scratch-verifisering: med standard threshold=[0]
|
// 0%->>0%-kryssinger, ikke ved at `top`/`bottom` passerer en INTERN
|
||||||
// fyrer den kun ved 0%->>0%-kryssinger (element helt inn/ut av roten),
|
// linje midt i et allerede-overlappende element).
|
||||||
// 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).
|
|
||||||
const inFlowButtonRef = useRef<HTMLDivElement>(null)
|
const inFlowButtonRef = useRef<HTMLDivElement>(null)
|
||||||
const [inFlowButtonVisible, setInFlowButtonVisible] = useState(true)
|
const [inFlowButtonVisible, setInFlowButtonVisible] = useState(true)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -6883,47 +6838,17 @@ function PlayerHoleCards({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Flytende "Registrer score" (V0-eksport, 2026-08-20, utløser-
|
{/* Kompakt "Score"-hurtigknapp (CompactScoreFab, redesignet
|
||||||
logikk rettet samme dag) -- vises når side-flyt-knappen over
|
2026-08-23 -- se CHANGELOG). Erstatter det tidligere fullbredde-
|
||||||
IKKE er synlig (uansett ÅRSAK: utvidet banekart ELLER en lang
|
bunnbanneret, som hadde tre runder med skjul/vis-bugs (#141/142,
|
||||||
hindringsliste alene, se `inFlowButtonVisible`-kommentaren over)
|
samt en fjerde runde der den ble hengende synlig over
|
||||||
OG spillerlisten fortsatt er utenfor synlig område (se
|
Kommentarer-seksjonen). En liten, hjørne-forankret FAB trenger
|
||||||
`playerListReached` -- uten denne dekket den delvis over
|
ikke den samme skjul-igjen-mekanismen -- den er liten nok til at
|
||||||
spillerkort 1 selv etter at brukeren allerede hadde scrollet
|
den ikke dekker nevneverdig over innhold, uansett hvor langt ned
|
||||||
dit). `fixed` posisjonerer mot selve viewporten (ingen ancestor
|
siden brukeren har scrollet. Vises alene når
|
||||||
her setter transform/filter, så det er trygt), gradient-scrim +
|
`!inFlowButtonVisible` (den vanlige, side-flyt-plasserte knappen
|
||||||
skygge løfter den visuelt over innholdet under,
|
er utenfor synlig område). */}
|
||||||
`env(safe-area-inset-bottom)` unngår at den klemmes helt inntil
|
<CompactScoreFab visible={!inFlowButtonVisible} onClick={scrollToPlayerList} />
|
||||||
kanten på telefoner med gest-navigasjon. `pointer-events-none`
|
|
||||||
på selve scrim-sonen når skjult/i overgang, slik at den aldri
|
|
||||||
blokkerer klikk på 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" />}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue