Help new visitors find the download path on hack pages (#83)
Some checks failed
Deploy Supabase Migrations to Production / migrate (push) Has been cancelled

* Help new visitors find the download path on hack pages.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Keep the download help tab above the mobile action bar shadow.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Give the version chip a pointer cursor.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Make the download help tab a bit larger.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jared Schoeny
2026-08-28 16:38:53 -06:00
committed by GitHub
parent 22ebbb78d0
commit 19c2cd65ae
8 changed files with 756 additions and 41 deletions

View File

@@ -75,8 +75,11 @@ body {
font-family: Arial, Helvetica, sans-serif;
}
/* Smooth scrolling and anchor offset for sticky header */
html { scroll-behavior: smooth; }
/* Smooth scrolling and stable layout when overlays lock page scrolling. */
html {
scroll-behavior: smooth;
scrollbar-gutter: stable;
}
:root { --anchor-offset: 38px; }
@media (min-width: 768px) { :root { --anchor-offset: 72px; } }
/* Modern browsers honor scroll-padding-top on the scroll container */
@@ -174,6 +177,55 @@ html { scroll-padding-top: var(--anchor-offset); }
.anim-pulse { animation: pulseSoft 2.4s ease-in-out infinite; }
.anim-pop { animation: popIn 220ms cubic-bezier(0.16, 1, 0.3, 1); }
/* Onboarding beacon: rose dot ringed against the page, breathing halo behind it.
The halo's static look is the reduced-motion resting state. */
@keyframes onboardingBeaconScale {
/* 10px beacon / 26px halo: begin at the beacon's outer edge. */
0% { transform: scale(0.385); }
64%, 100% { transform: scale(1.4); }
}
@keyframes onboardingBeaconFade {
0%, 34% { opacity: 0.55; }
64%, 100% { opacity: 0; }
}
@keyframes onboardingBeaconFadeDark {
0% { opacity: 1; }
64%, 100% { opacity: 0; }
}
.onboarding-beacon {
position: absolute;
display: block;
z-index: 3;
width: 10px;
height: 10px;
border-radius: 50%;
background: #f43f5e;
outline: 2px solid var(--background);
}
.onboarding-beacon-large {
width: 12px;
height: 12px;
}
.onboarding-beacon::after {
content: "";
position: absolute;
inset: -8px;
border-radius: 50%;
background: rgba(244, 63, 94, 0.45);
transform-origin: center;
animation:
onboardingBeaconScale 1.5s cubic-bezier(0, 0, 0.2, 1) infinite,
onboardingBeaconFade 1.5s linear infinite;
}
@media (prefers-color-scheme: dark) {
.onboarding-beacon::after {
background: rgba(244, 63, 94, 0.95);
animation:
onboardingBeaconScale 1.5s cubic-bezier(0, 0, 0.2, 1) infinite,
onboardingBeaconFadeDark 1.5s linear infinite;
}
}
@keyframes heroConnectorDash {
to { stroke-dashoffset: -24; }
}
@@ -187,6 +239,7 @@ html { scroll-padding-top: var(--anchor-offset); }
.anim-float,
.anim-pulse,
.anim-pop,
.onboarding-beacon::after,
.hero-connector-path,
.hero-hack-image {
animation: none !important;

View File

@@ -3,7 +3,9 @@
import React from "react";
import StickyActionBar from "@/components/Hack/StickyActionBar";
import BaseRomErrorModal, { type BaseRomErrorModalState } from "@/components/Hack/BaseRomErrorModal";
import HackOnboardingOverlay from "@/components/Hack/Onboarding/HackOnboardingOverlay";
import { useBaseRoms } from "@/contexts/BaseRomContext";
import { useHackOnboarding } from "@/hooks/useHackOnboarding";
import { baseRoms } from "@/data/baseRoms";
import type { DownloadEventDetail } from "@/types/util";
import { getSignedPatchUrl, updatePatchDownloadCount, reportPatchDownloadEvent } from "@/app/hack/[slug]/actions";
@@ -73,7 +75,17 @@ const HackActions: React.FC<HackActionsProps> = ({
hackSlug,
patcherSelector,
}) => {
const { isLinked, hasPermission, hasCached, importUploadedBlob, ensurePermission, linkRom, getFileBlob, supported } = useBaseRoms();
const {
isLinked,
hasPermission,
hasCached,
importUploadedBlob,
ensurePermission,
linkRom,
getFileBlob,
supported,
loading: baseRomsLoading,
} = useBaseRoms();
const [file, setFile] = React.useState<File | null>(null);
const [status, setStatus] = React.useState<"idle" | "ready" | "patching" | "done" | "downloading">("idle");
const [patchProgress, setPatchProgress] = React.useState<number | null>(null);
@@ -85,9 +97,18 @@ const HackActions: React.FC<HackActionsProps> = ({
const [romErrorModal, setRomErrorModal] = React.useState<BaseRomErrorModalState | null>(null);
const [isVerifyingRom, setIsVerifyingRom] = React.useState(false);
const [selectedPatchId, setSelectedPatchId] = React.useState<number | null>(patcherSelector.defaultPatchId);
const [versionPickerOpen, setVersionPickerOpen] = React.useState(false);
const selectedPatchIdRef = React.useRef(selectedPatchId);
const fetchSessionIdRef = React.useRef<string | null | undefined>(undefined);
const linkInteractionPendingRef = React.useRef(false);
selectedPatchIdRef.current = selectedPatchId;
const romReady = hasPermission(baseRomId) || hasCached(baseRomId);
const onboarding = useHackOnboarding({
hasVersionPicker: patcherSelector.selectablePatches.length > 1,
romReady,
romReadyKnown: !baseRomsLoading,
versionPickerOpen,
});
function getFetchSessionId(): string | null {
if (fetchSessionIdRef.current !== undefined) {
@@ -119,6 +140,12 @@ const HackActions: React.FC<HackActionsProps> = ({
setSelectedPatchId(patcherSelector.defaultPatchId);
}, [patcherSelector.defaultPatchId]);
React.useEffect(() => {
if (!romReady || !linkInteractionPendingRef.current) return;
linkInteractionPendingRef.current = false;
onboarding.onHackPageRomSupplied();
}, [romReady, onboarding.onHackPageRomSupplied]);
function resetPatchSession() {
setTermsAgreed(false);
setPatchUrl(null);
@@ -128,6 +155,7 @@ const HackActions: React.FC<HackActionsProps> = ({
}
function onVersionChange(nextPatchId: number) {
onboarding.onVersionConfirmed();
if (nextPatchId === selectedPatchId) return;
selectedPatchIdRef.current = nextPatchId;
setSelectedPatchId(nextPatchId);
@@ -244,6 +272,7 @@ const HackActions: React.FC<HackActionsProps> = ({
await importUploadedBlob(f);
setFile(f);
setStatus("ready");
onboarding.onHackPageRomSupplied();
} finally {
setIsVerifyingRom(false);
}
@@ -495,6 +524,7 @@ const HackActions: React.FC<HackActionsProps> = ({
await discardSink();
return;
}
onboarding.onHackPageRomSupplied();
}
const linkedFile = await getFileBlob(baseRomId);
if (!linkedFile) {
@@ -537,6 +567,7 @@ const HackActions: React.FC<HackActionsProps> = ({
}
setStatus("done");
onboarding.onPatchSucceeded();
// Best-effort log applied event for counting and animate badge
try {
@@ -580,6 +611,17 @@ const HackActions: React.FC<HackActionsProps> = ({
}
}
async function onClickLink() {
if (isLinked(baseRomId)) {
const permission = await ensurePermission(baseRomId, true);
if (permission === "granted") onboarding.onHackPageRomSupplied();
return;
}
linkInteractionPendingRef.current = true;
await linkRom(baseRomId);
}
return (
<>
<StickyActionBar
@@ -588,6 +630,11 @@ const HackActions: React.FC<HackActionsProps> = ({
selectablePatches={patcherSelector.selectablePatches}
selectedPatchId={selectedPatch?.id ?? selectedPatchId}
onVersionChange={onVersionChange}
onVersionPickerOpenChange={setVersionPickerOpen}
onboardingHighlight={onboarding.cardOpen ? onboarding.currentStep : null}
onboardingDimBar={onboarding.cardOpen && onboarding.locked && !onboarding.hideForPicker}
onboardingGateLabel={onboarding.showGate ? onboarding.gateLabel : null}
onOnboardingGateClick={onboarding.openFromGate}
author={author}
filename={selectedFilename}
baseRomName={baseRomName}
@@ -596,14 +643,26 @@ const HackActions: React.FC<HackActionsProps> = ({
status={status}
error={error}
isLinked={isLinked(baseRomId)}
romReady={hasPermission(baseRomId) || hasCached(baseRomId)}
onClickLink={() => (isLinked(baseRomId) ? ensurePermission(baseRomId, true) : linkRom(baseRomId))}
romReady={romReady}
onClickLink={onClickLink}
supported={supported}
onUploadChange={onSelectFile}
termsAgreed={termsAgreed}
isVerifyingRom={isVerifyingRom}
patchProgress={patchProgress}
/>
{onboarding.cardOpen && onboarding.currentStep && (
<HackOnboardingOverlay
step={onboarding.currentStep}
locked={onboarding.locked}
isLastStep={onboarding.isLastStep}
hide={onboarding.hideForPicker}
onNext={onboarding.next}
onGotIt={onboarding.gotIt}
onDismiss={onboarding.dismissCard}
baseRomName={baseRomName}
/>
)}
{romErrorModal && (
<BaseRomErrorModal
state={romErrorModal}

View File

@@ -0,0 +1,38 @@
"use client";
import React from "react";
import { FiChevronDown, FiHelpCircle } from "react-icons/fi";
interface HackOnboardingGateProps {
label: string;
onClick: () => void;
}
/**
* Opt-in entry point for the hack page onboarding tour.
*
* Positions itself against the StickyActionBar root, so it must be rendered
* inside that root: an attached tab under the desktop bar (1px overlap, no top
* border) and a detached frost pill floating above the mobile sheet.
*/
export default function HackOnboardingGate({ label, onClick }: HackOnboardingGateProps) {
return (
<button
type="button"
onClick={onClick}
className="group/gate absolute bottom-full left-1/2 z-10 mb-3 -translate-x-1/2 inline-flex cursor-pointer items-center gap-2 whitespace-nowrap rounded-full border border-[var(--border)] bg-[var(--surface-2)]/80 px-5 py-2.5 text-sm font-semibold text-foreground/80 shadow-sm backdrop-blur transition-colors supports-[backdrop-filter]:bg-[color-mix(in_oklab,var(--background)_90%,transparent)] hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] after:absolute after:-inset-x-5 after:-top-4 after:-bottom-3 after:content-[''] md:after:hidden md:bottom-auto md:top-full md:mb-0 md:-mt-px md:rounded-t-none md:rounded-b-xl md:border-t-0 md:px-6 md:pt-4 md:pb-3 md:shadow-none md:supports-[backdrop-filter]:bg-[color-mix(in_oklab,var(--background)_70%,transparent)]"
>
<FiHelpCircle
size={16}
aria-hidden
className="shrink-0 text-[var(--accent)]"
/>
{label}
<FiChevronDown
size={15}
aria-hidden
className="shrink-0 text-foreground/60 transition-transform duration-200 group-hover/gate:translate-y-0.5"
/>
</button>
);
}

View File

@@ -0,0 +1,253 @@
"use client";
import React from "react";
import Link from "next/link";
import { FiX } from "react-icons/fi";
export type HackOnboardingStep = "version" | "selectRom" | "agree";
interface HackOnboardingOverlayProps {
step: HackOnboardingStep;
/** Tour opened from the gate traps focus and locks scroll. Passive nudges do not. */
locked: boolean;
isLastStep: boolean;
onNext: () => void;
onGotIt: () => void;
/** Closes the card without marking steps dismissed. Fired by × and Escape. */
onDismiss: () => void;
/** Set while the version picker owns the screen. */
hide: boolean;
/** Shown in the Select ROM wait copy, e.g. "Pokemon Emerald". */
baseRomName?: string | null;
}
const STEP_COPY: Record<HackOnboardingStep, { title: string; body: React.ReactNode }> = {
version: {
title: "Choose your version",
body: "This hack has more than one release. Pick the version you want here before you patch.",
},
selectRom: {
title: "The key to your download",
body: "Hackdex does not host ROM files. You supply a clean base ROM for patching. It will be cached in your browser so you can keep using it later for other hacks.",
},
agree: {
title: "One last step",
body: (
<>
Excellent! Now press &quot;Agree and Patch&quot; to accept{" "}
<Link
href="/terms"
target="_blank"
rel="noopener noreferrer"
className="underline text-foreground/85 hover:text-foreground"
>
the Terms
</Link>{" "}
and build the patched ROM in your browser so you can start playing.
</>
),
},
};
interface Anchor {
barTop: number;
barBottom: number;
barLeft: number;
barRight: number;
viewportWidth: number;
viewportHeight: number;
desktop: boolean;
}
function sameAnchor(a: Anchor, b: Anchor) {
return (
a.barTop === b.barTop &&
a.barBottom === b.barBottom &&
a.barLeft === b.barLeft &&
a.barRight === b.barRight &&
a.viewportWidth === b.viewportWidth &&
a.viewportHeight === b.viewportHeight &&
a.desktop === b.desktop
);
}
/**
* Tracks the live geometry of the sticky action bar so the coach card can sit
* under it on desktop and above the sheet on mobile, whatever height it has.
*/
function useBarAnchor(active: boolean): Anchor | null {
const [anchor, setAnchor] = React.useState<Anchor | null>(null);
React.useEffect(() => {
if (!active) return;
const bar = document.querySelector<HTMLElement>("[data-hack-action-bar]");
if (!bar) return;
const measure = () => {
const rect = bar.getBoundingClientRect();
const next: Anchor = {
barTop: rect.top,
barBottom: rect.bottom,
barLeft: rect.left,
barRight: rect.right,
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight,
desktop: window.matchMedia("(min-width: 768px)").matches,
};
setAnchor((previous) => (previous && sameAnchor(previous, next) ? previous : next));
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(bar);
window.addEventListener("scroll", measure, { passive: true });
window.addEventListener("resize", measure);
return () => {
observer.disconnect();
window.removeEventListener("scroll", measure);
window.removeEventListener("resize", measure);
};
}, [active]);
return anchor;
}
function cardPosition(anchor: Anchor, step: HackOnboardingStep): React.CSSProperties {
if (!anchor.desktop) {
return { left: 16, right: 16, bottom: anchor.viewportHeight - anchor.barTop + 12 };
}
return {
top: anchor.barBottom + 14,
width: 280,
// Step 1 points back at the version chip; the ROM steps follow the CTA.
...(step === "version"
? { left: anchor.barLeft }
: { right: anchor.viewportWidth - anchor.barRight }),
};
}
/**
* Coach mark for the hack page onboarding. The dimmed bar, lit control and rose
* beacon are drawn by StickyActionBar; this owns the scrim and the card.
*/
export default function HackOnboardingOverlay({
step,
locked,
isLastStep,
onNext,
onGotIt,
onDismiss,
hide,
baseRomName,
}: HackOnboardingOverlayProps) {
const cardRef = React.useRef<HTMLDivElement | null>(null);
const titleId = React.useId();
const anchor = useBarAnchor(!hide);
const copy = STEP_COPY[step];
React.useEffect(() => {
if (hide) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") onDismiss();
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [hide, onDismiss]);
React.useEffect(() => {
if (hide || !locked) return;
const previous = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = previous;
};
}, [hide, locked]);
// The card only mounts once the bar has been measured, so wait for it.
const cardMounted = anchor !== null;
React.useEffect(() => {
if (hide || !locked || !cardMounted) return;
const card = cardRef.current;
if (!card) return;
card.focus();
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Tab") return;
const spotlight = Array.from(
document.querySelectorAll<HTMLElement>("[data-onboarding-spotlight]:not([disabled])"),
);
const cardControls = Array.from(
card.querySelectorAll<HTMLElement>("button:not([disabled]), a[href]"),
);
const focusable = [...spotlight, ...cardControls];
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (!first) return;
const active = document.activeElement;
const inCycle = active instanceof HTMLElement && focusable.includes(active);
if (active === (event.shiftKey ? first : last) || !inCycle) {
event.preventDefault();
(event.shiftKey ? last : first).focus();
}
};
document.addEventListener("keydown", onKeyDown, true);
return () => document.removeEventListener("keydown", onKeyDown, true);
}, [hide, locked, cardMounted]);
if (hide) return null;
return (
<>
<div
aria-hidden
className={`fixed inset-0 z-50 ${
locked
? "bg-[#171717]/25 dark:bg-black/55"
: "pointer-events-none bg-[#171717]/8 dark:bg-black/20"
}`}
/>
{anchor && (
<div
ref={cardRef}
role="dialog"
aria-modal={locked || undefined}
aria-labelledby={titleId}
tabIndex={-1}
style={cardPosition(anchor, step)}
className="anim-pop elevate fixed z-[80] rounded-xl border border-[var(--border)] bg-white p-3.5 outline-none dark:bg-[#141414]"
>
{(step === "selectRom" || !isLastStep) && (
<button
type="button"
onClick={onDismiss}
aria-label="Close"
className="absolute right-2 top-2 cursor-pointer rounded-md p-1 text-foreground/50 transition-colors hover:bg-[var(--surface-2)] hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)]"
>
<FiX size={14} aria-hidden />
</button>
)}
<h4 id={titleId} className={`text-[13px] font-semibold ${step === "selectRom" || !isLastStep ? "pr-7" : ""}`}>
{copy.title}
</h4>
<p className="mt-1 text-xs leading-relaxed text-foreground/70">{copy.body}</p>
{step === "selectRom" ? (
<p className="mt-3 text-xs font-semibold text-foreground/75">
Select a clean {baseRomName || "base"} ROM to continue.
</p>
) : (
<div className="mt-3 flex justify-end">
<button
type="button"
onClick={isLastStep ? onGotIt : onNext}
className="inline-flex h-7 cursor-pointer items-center rounded-full bg-[var(--accent)] px-3 text-xs font-bold text-[var(--accent-foreground)] transition-[background-color,transform] hover:bg-[var(--accent-700)] active:scale-[0.98] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--background)]"
>
{isLastStep ? "Got it" : "Next"}
</button>
</div>
)}
</div>
)}
</>
);
}

View File

@@ -0,0 +1,2 @@
export { default as HackOnboardingGate } from "./HackOnboardingGate";
export { default as HackOnboardingOverlay, type HackOnboardingStep } from "./HackOnboardingOverlay";

View File

@@ -6,6 +6,9 @@ import { FiChevronDown, FiX } from "react-icons/fi";
import { platformAccept } from "@/utils/idb";
import { useBaseRoms } from "@/contexts/BaseRomContext";
import type { Platform } from "@/data/baseRoms";
import HackOnboardingGate from "@/components/Hack/Onboarding/HackOnboardingGate";
type OnboardingTarget = "version" | "selectRom" | "agree";
interface StickyActionBarProps {
title: string;
@@ -28,6 +31,15 @@ interface StickyActionBarProps {
termsAgreed: boolean;
isVerifyingRom?: boolean;
patchProgress?: number | null;
onVersionPickerOpenChange?: (open: boolean) => void;
/** Control the onboarding is pointing at. Dims the bar around it and lights it up. */
onboardingHighlight?: OnboardingTarget | null;
onboardingDimBar?: boolean;
/** The beacon rides along with `onboardingHighlight`; set false to suppress it. */
onboardingBeacon?: boolean;
/** Renders the onboarding opt-in gate when both gate props are supplied. */
onboardingGateLabel?: string | null;
onOnboardingGateClick?: () => void;
}
export default function StickyActionBar({
@@ -51,6 +63,12 @@ export default function StickyActionBar({
termsAgreed,
isVerifyingRom = false,
patchProgress = null,
onVersionPickerOpenChange,
onboardingHighlight = null,
onboardingDimBar = false,
onboardingBeacon = true,
onboardingGateLabel = null,
onOnboardingGateClick,
}: StickyActionBarProps) {
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => setMounted(true), []);
@@ -61,6 +79,25 @@ export default function StickyActionBar({
const [patchAgainReady, setPatchAgainReady] = React.useState(true);
const [versionPickerOpen, setVersionPickerOpen] = React.useState(false);
const hasVersionPicker = selectablePatches.length > 1 && !!onVersionChange;
const onboardingActive = onboardingDimBar || onboardingHighlight !== null;
// Reported through a ref so an inline parent callback cannot loop the effect.
const versionPickerOpenChange = React.useRef(onVersionPickerOpenChange);
React.useEffect(() => {
versionPickerOpenChange.current = onVersionPickerOpenChange;
}, [onVersionPickerOpenChange]);
React.useEffect(() => {
versionPickerOpenChange.current?.(versionPickerOpen);
}, [versionPickerOpen]);
// Lifts the target above the dim layer and rings it with the spotlight.
const isSpotlight = (target: OnboardingTarget) => onboardingHighlight === target;
const spotlight = (target: OnboardingTarget) =>
isSpotlight(target)
? " relative z-[2] outline-[3px] outline-offset-2 outline-[color-mix(in_oklab,var(--accent)_45%,transparent)]"
: "";
const spotlightAttr = (target: OnboardingTarget) =>
isSpotlight(target) || undefined;
// Keep error mounted to allow fade-out when error becomes null
React.useEffect(() => {
@@ -99,8 +136,23 @@ export default function StickyActionBar({
};
return (
<div className="fixed inset-x-0 bottom-0 z-40 md:sticky md:top-18 md:z-30 flex flex-col gap-2 pb-safe">
<div className="mx-auto w-full lg:max-w-screen-lg flex flex-col md:flex-row md:items-center md:justify-between md:gap-4 rounded-t-xl md:rounded-md border border-[var(--border)] bg-[var(--surface-2)]/80 px-4 py-3 pb-[env(safe-area-inset-bottom)] md:pb-3 shadow-[0_-6px_24px_rgba(0,0,0,0.2)] md:shadow-none backdrop-blur supports-[backdrop-filter]:bg-[color-mix(in_oklab,var(--background)_90%,transparent)] md:supports-[backdrop-filter]:bg-[color-mix(in_oklab,var(--background)_70%,transparent)]">
<div
className={`fixed inset-x-0 bottom-0 md:sticky md:top-18 flex flex-col gap-2 pb-safe ${
// Raised above the coach scrim so the lit control stays reachable.
onboardingActive ? "z-[60] md:z-[60]" : "z-40 md:z-30"
}`}
>
{/* The error banner occupies the same edge as the gate, so it wins. */}
{onboardingGateLabel && onOnboardingGateClick && errorMessage === null && (
<HackOnboardingGate label={onboardingGateLabel} onClick={onOnboardingGateClick} />
)}
<div data-hack-action-bar className="relative mx-auto w-full lg:max-w-screen-lg flex flex-col md:flex-row md:items-center md:justify-between md:gap-4 rounded-t-xl md:rounded-md border border-[var(--border)] bg-[var(--surface-2)]/80 px-4 py-3 pb-[env(safe-area-inset-bottom)] md:pb-3 shadow-[0_-6px_24px_rgba(0,0,0,0.2)] md:shadow-none backdrop-blur supports-[backdrop-filter]:bg-[color-mix(in_oklab,var(--background)_90%,transparent)] md:supports-[backdrop-filter]:bg-[color-mix(in_oklab,var(--background)_70%,transparent)]">
{onboardingDimBar && (
<div
aria-hidden
className="absolute inset-0 z-[1] rounded-t-xl md:rounded-md bg-[#171717]/25 dark:bg-black/55"
/>
)}
<div className="md:w-fit md:max-w-[40%] lg:max-w-[45%]">
<div className="flex items-center gap-2">
<div className="truncate text-xl font-bold md:text-sm md:font-medium">{title}</div>
@@ -110,8 +162,9 @@ export default function StickyActionBar({
aria-label="Patch version"
aria-haspopup="dialog"
aria-expanded={versionPickerOpen}
data-onboarding-spotlight={spotlightAttr("version")}
onClick={() => setVersionPickerOpen((open) => !open)}
className="shrink-0 max-w-44 ml-auto md:ml-0 inline-flex items-center gap-1.5 rounded-full border border-[var(--border)] bg-[var(--surface-2)] px-2.5 py-1 text-[11px] font-semibold text-foreground/90 shadow-sm focus:outline-none md:hover:bg-[var(--surface-3)] md:focus:ring-2 md:focus:ring-[var(--accent)]"
className={`relative shrink-0 max-w-44 ml-auto md:ml-0 inline-flex items-center gap-1.5 rounded-full border border-[var(--border)] bg-[var(--surface-2)] px-2.5 py-1 text-[11px] font-semibold text-foreground/90 shadow-sm cursor-pointer focus:outline-none md:hover:bg-[var(--surface-3)] md:focus:ring-2 md:focus:ring-[var(--accent)]${spotlight("version")}`}
>
<span className="truncate">{version}</span>
{versionPickerOpen ? (
@@ -119,6 +172,9 @@ export default function StickyActionBar({
) : (
<FiChevronDown size={13} className="shrink-0 text-foreground/65" aria-hidden />
)}
{onboardingBeacon && onboardingHighlight === "version" && (
<OnboardingBeacon className="-top-[3px] -right-[3px]" />
)}
</button>
) : version && (
<span className="shrink-0 rounded-full bg-[var(--surface-2)] ml-auto md:ml-0 px-2 py-0.5 text-[11px] font-medium text-foreground/85 ring-1 ring-[var(--border)]">{version}</span>
@@ -127,7 +183,7 @@ export default function StickyActionBar({
<div className="truncate text-sm md:text-xs text-foreground/60">By {author}</div>
</div>
{hasVersionPicker && versionPickerOpen && (
<div className="md:hidden mt-3 mb-4 animate-in fade-in slide-in-from-bottom-2 duration-200">
<div className="relative z-[2] md:hidden mt-3 mb-4 animate-in fade-in slide-in-from-bottom-2 duration-200">
<div className="mb-2">
<div className="text-xs font-semibold uppercase tracking-wide text-foreground/55">Select version</div>
</div>
@@ -174,22 +230,30 @@ export default function StickyActionBar({
disabled={isVerifyingRom}
className="hidden"
/>
<button
type="button"
onClick={() => uploadInputRef.current?.click()}
disabled={isVerifyingRom}
className="shine-wrap btn-premium h-11 md:h-9 w-5/6 mx-auto md:w-auto md:mx-0 md:min-w-34 text-base md:text-sm font-semibold cursor-pointer disabled:cursor-not-allowed disabled:opacity-70"
>
{isVerifyingRom ? (
<span>Verifying…</span>
) : baseRomName ? (
<span>Select <span className="font-bold">{baseRomName}</span> ROM</span>
) : baseRomPlatform ? (
<span>Select <span className="font-bold">{baseRomPlatform}</span> ROM</span>
) : (
<span>Select Base ROM</span>
{/* The button clips its own overflow for the shine sweep, so the
beacon rides on this wrapper instead. */}
<span className="relative inline-flex w-5/6 mx-auto md:w-auto md:mx-0">
<button
type="button"
onClick={() => uploadInputRef.current?.click()}
disabled={isVerifyingRom}
data-onboarding-spotlight={spotlightAttr("selectRom")}
className={`shine-wrap btn-premium h-11 md:h-9 w-full md:w-auto md:min-w-34 text-base md:text-sm font-semibold cursor-pointer disabled:cursor-not-allowed disabled:opacity-70${spotlight("selectRom")}`}
>
{isVerifyingRom ? (
<span>Verifying…</span>
) : baseRomName ? (
<span>Select <span className="font-bold">{baseRomName}</span> ROM</span>
) : baseRomPlatform ? (
<span>Select <span className="font-bold">{baseRomPlatform}</span> ROM</span>
) : (
<span>Select Base ROM</span>
)}
</button>
{onboardingBeacon && onboardingHighlight === "selectRom" && (
<OnboardingBeacon large className="-top-[3px] right-[2px]" />
)}
</button>
</span>
</label>
)}
{!baseRomsLoading && !romReady && isLinked && (
@@ -197,29 +261,43 @@ export default function StickyActionBar({
type="button"
onClick={onClickLink}
disabled={!supported}
className="w-5/6 md:w-auto rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-3 py-2 text-sm md:text-xs cursor-pointer hover:bg-[var(--surface-3)] hover:text-foreground disabled:cursor-not-allowed disabled:opacity-60"
data-onboarding-spotlight={spotlightAttr("selectRom")}
className={`relative w-5/6 md:w-auto rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-3 py-2 text-sm md:text-xs cursor-pointer hover:bg-[var(--surface-3)] hover:text-foreground disabled:cursor-not-allowed disabled:opacity-60${spotlight("selectRom")}`}
>
Grant permission
{onboardingBeacon && onboardingHighlight === "selectRom" && (
<OnboardingBeacon large className="-top-[3px] -right-[3px]" />
)}
</button>
)}
<button
onClick={onPatch}
{/* Wrapper owns the layout slot so the beacon can escape the button's
overflow clip. Hiding it here keeps the flex gap collapsed. */}
<span
data-ready={romReady}
disabled={!mounted || !romReady || (status !== "ready" && status !== "done" && status !== "idle") || !patchAgainReady}
className={`shine-wrap btn-premium data-[ready=false]:hidden! h-11 md:h-9 w-full md:min-w-46 ${!termsAgreed || status === 'downloading' ? "md:w-32" : "md:w-auto"} text-base md:text-sm font-semibold cursor-pointer disabled:cursor-not-allowed disabled:opacity-70 ${romReady && status !== 'downloading' && status !== 'ready' && termsAgreed ? "mt-6 md:mt-0" : ""}`}
className={`relative inline-flex data-[ready=false]:hidden! w-full md:w-auto ${romReady && status !== 'downloading' && status !== 'ready' && termsAgreed ? "mt-6 md:mt-0" : ""}`}
>
<span>{
status === "patching" ? (
patchProgress != null && patchProgress > 0
? `Patching… (${(patchProgress / (1024 * 1024)).toFixed(0)} MB)`
: "Patching…"
) :
status === "downloading" ? "Downloading…" :
status === "done" ? (
patchAgainReady ? "Patch Again" : "Patched"
) : termsAgreed ? "Retry Patching" : "Agree and Patch"
}</span>
</button>
<button
onClick={onPatch}
data-onboarding-spotlight={spotlightAttr("agree")}
disabled={!mounted || !romReady || (status !== "ready" && status !== "done" && status !== "idle") || !patchAgainReady}
className={`shine-wrap btn-premium h-11 md:h-9 w-full md:min-w-46 ${!termsAgreed || status === 'downloading' ? "md:w-32" : "md:w-auto"} text-base md:text-sm font-semibold cursor-pointer disabled:cursor-not-allowed disabled:opacity-70${spotlight("agree")}`}
>
<span>{
status === "patching" ? (
patchProgress != null && patchProgress > 0
? `Patching… (${(patchProgress / (1024 * 1024)).toFixed(0)} MB)`
: "Patching…"
) :
status === "downloading" ? "Downloading…" :
status === "done" ? (
patchAgainReady ? "Patch Again" : "Patched"
) : termsAgreed ? "Retry Patching" : "Agree and Patch"
}</span>
</button>
{onboardingBeacon && onboardingHighlight === "agree" && (
<OnboardingBeacon large className="-top-[3px] right-[2px]" />
)}
</span>
</div>
</div>
{hasVersionPicker && versionPickerOpen && (
@@ -265,6 +343,16 @@ export default function StickyActionBar({
);
}
/** Rose onboarding beacon with a reduced-motion-safe halo. */
function OnboardingBeacon({ className, large = false }: { className: string; large?: boolean }) {
return (
<span
aria-hidden
className={`onboarding-beacon ${large ? "onboarding-beacon-large" : ""} pointer-events-none absolute! block overflow-visible ${className}`}
/>
);
}
function VersionRadioList({
patches,
selectedPatchId,

View File

@@ -0,0 +1,148 @@
"use client";
import React from "react";
import {
DEFAULT_HACK_ONBOARDING_STATE,
getLeftoverHackOnboardingSteps,
readHackOnboardingState,
writeHackOnboardingState,
type HackOnboardingState,
type HackOnboardingStep,
type HackOnboardingStepState,
} from "@/utils/hack-onboarding";
interface UseHackOnboardingOptions {
hasVersionPicker: boolean;
romReady: boolean;
romReadyKnown?: boolean;
versionPickerOpen: boolean;
}
export function useHackOnboarding({
hasVersionPicker,
romReady,
romReadyKnown = true,
versionPickerOpen,
}: UseHackOnboardingOptions) {
const [steps, setSteps] = React.useState<HackOnboardingState>(DEFAULT_HACK_ONBOARDING_STATE);
const [hydrated, setHydrated] = React.useState(false);
const [tourSteps, setTourSteps] = React.useState<HackOnboardingStep[]>([]);
const [locked, setLocked] = React.useState(false);
const previousRomReady = React.useRef<boolean | null>(null);
React.useEffect(() => {
setSteps(readHackOnboardingState());
setHydrated(true);
}, []);
const updateStep = React.useCallback((step: HackOnboardingStep, value: HackOnboardingStepState) => {
setSteps((current) => {
const next = { ...current, [step]: value };
writeHackOnboardingState(next);
return next;
});
}, []);
const leftoverSteps = React.useMemo(
() => getLeftoverHackOnboardingSteps(steps, { hasVersionPicker, romReady }),
[steps, hasVersionPicker, romReady],
);
const currentStep = tourSteps[0] ?? null;
const cardOpen = currentStep !== null;
const closeCard = React.useCallback(() => {
setTourSteps([]);
setLocked(false);
}, []);
const finishCurrentStep = React.useCallback((value: HackOnboardingStepState) => {
if (!currentStep) return;
updateStep(currentStep, value);
setTourSteps((current) => current.slice(1));
if (tourSteps.length === 1) setLocked(false);
}, [currentStep, tourSteps.length, updateStep]);
const openFromGate = React.useCallback(() => {
if (!romReadyKnown || leftoverSteps.length === 0) return;
setTourSteps(leftoverSteps);
setLocked(true);
}, [romReadyKnown, leftoverSteps]);
const next = React.useCallback(() => {
finishCurrentStep("dismissed");
}, [finishCurrentStep]);
const gotIt = React.useCallback(() => {
finishCurrentStep("dismissed");
}, [finishCurrentStep]);
const dismissCard = React.useCallback(() => {
closeCard();
}, [closeCard]);
const onVersionConfirmed = React.useCallback(() => {
if (!hasVersionPicker) return;
updateStep("version", "passed");
if (currentStep === "version") {
setTourSteps((current) => current.slice(1));
if (tourSteps.length === 1) setLocked(false);
}
}, [hasVersionPicker, currentStep, tourSteps.length, updateStep]);
const onHackPageRomSupplied = React.useCallback(() => {
updateStep("selectRom", "passed");
}, [updateStep]);
const onPatchSucceeded = React.useCallback(() => {
updateStep("agree", "passed");
closeCard();
}, [updateStep, closeCard]);
React.useEffect(() => {
if (!romReadyKnown) return;
if (previousRomReady.current === null) {
previousRomReady.current = romReady;
return;
}
const becameReady = !previousRomReady.current && romReady;
previousRomReady.current = romReady;
if (!hydrated || !becameReady) return;
// Selecting a ROM always continues to Agree, even if leftover no longer
// lists it (Agree may already be passed or dismissed).
if (locked && tourSteps.length > 0) {
setTourSteps((current) => {
const withoutSelectRom = current.filter((step) => step !== "selectRom");
return withoutSelectRom.includes("agree") ? withoutSelectRom : [...withoutSelectRom, "agree"];
});
return;
}
if (tourSteps.length === 0) {
setTourSteps(["agree"]);
setLocked(false);
}
}, [hydrated, locked, romReady, romReadyKnown, tourSteps.length]);
const gateLabel = leftoverSteps.length === 1 && leftoverSteps[0] === "version"
? "How do I select a version?"
: "How do I download?";
return {
showGate: hydrated && romReadyKnown && !cardOpen && leftoverSteps.length > 0,
gateLabel,
cardOpen,
locked,
currentStep,
isLastStep: tourSteps.length === 1,
hideForPicker: cardOpen && versionPickerOpen,
openFromGate,
next,
gotIt,
dismissCard,
onVersionConfirmed,
onHackPageRomSupplied,
onPatchSucceeded,
};
}

View File

@@ -0,0 +1,74 @@
import { getOrCreateDeviceId } from "@/utils/deviceId";
export const HACK_ONBOARDING_STEPS = ["version", "selectRom", "agree"] as const;
export type HackOnboardingStep = (typeof HACK_ONBOARDING_STEPS)[number];
export type HackOnboardingStepState = "undone" | "passed" | "dismissed";
export type HackOnboardingState = Record<HackOnboardingStep, HackOnboardingStepState>;
const STORAGE_KEY_PREFIX = "hackOnboarding";
export const DEFAULT_HACK_ONBOARDING_STATE: HackOnboardingState = {
version: "undone",
selectRom: "undone",
agree: "undone",
};
function storageKey(deviceId: string): string {
return `${STORAGE_KEY_PREFIX}:${deviceId}`;
}
function isStepState(value: unknown): value is HackOnboardingStepState {
return value === "undone" || value === "passed" || value === "dismissed";
}
export function readHackOnboardingState(): HackOnboardingState {
if (typeof localStorage === "undefined") return { ...DEFAULT_HACK_ONBOARDING_STATE };
try {
const deviceId = getOrCreateDeviceId();
if (!deviceId) return { ...DEFAULT_HACK_ONBOARDING_STATE };
const stored = JSON.parse(localStorage.getItem(storageKey(deviceId)) ?? "{}") as Record<string, unknown>;
return {
version: isStepState(stored.version) ? stored.version : "undone",
selectRom: isStepState(stored.selectRom) ? stored.selectRom : "undone",
agree: isStepState(stored.agree) ? stored.agree : "undone",
};
} catch {
return { ...DEFAULT_HACK_ONBOARDING_STATE };
}
}
export function writeHackOnboardingState(state: HackOnboardingState): void {
if (typeof localStorage === "undefined") return;
try {
const deviceId = getOrCreateDeviceId();
if (deviceId) localStorage.setItem(storageKey(deviceId), JSON.stringify(state));
} catch {
// Onboarding should not interfere with patching when storage is unavailable.
}
}
interface LeftoverStepsOptions {
hasVersionPicker: boolean;
romReady: boolean;
}
export function getLeftoverHackOnboardingSteps(
state: HackOnboardingState,
{ hasVersionPicker, romReady }: LeftoverStepsOptions,
): HackOnboardingStep[] {
const steps: HackOnboardingStep[] = [];
const selectRomNeeded = !romReady && state.selectRom === "undone";
if (hasVersionPicker && state.version === "undone") steps.push("version");
if (selectRomNeeded) steps.push("selectRom");
// A tour that still needs Select always includes Agree, even if Agree was
// already passed or dismissed. Selecting the ROM is the first half of that pair.
if (state.agree === "undone" || selectRomNeeded) steps.push("agree");
return steps;
}