From 627454971cba708f8b4fe32a26f57cdf8d6ab1cd Mon Sep 17 00:00:00 2001 From: ZL-Q Date: Sun, 10 May 2026 15:22:08 +0800 Subject: [PATCH] fix: improve web auth refresh and mobile tutorials --- .trellis/spec/web/index.md | 7 ++ .../check.jsonl | 1 + .../implement.jsonl | 1 + .../prd.md | 53 ++++++++++++++ .../task.json | 26 +++++++ web/src/components/AutoDivinationPage.tsx | 56 ++++++++++----- web/src/components/ManualDivinationPage.tsx | 70 ++++++++++++------- web/src/lib/auth.ts | 15 +++- 8 files changed, 183 insertions(+), 46 deletions(-) create mode 100644 .trellis/tasks/05-10-fix-mobile-divination-tutorial-overlay/check.jsonl create mode 100644 .trellis/tasks/05-10-fix-mobile-divination-tutorial-overlay/implement.jsonl create mode 100644 .trellis/tasks/05-10-fix-mobile-divination-tutorial-overlay/prd.md create mode 100644 .trellis/tasks/05-10-fix-mobile-divination-tutorial-overlay/task.json diff --git a/.trellis/spec/web/index.md b/.trellis/spec/web/index.md index ee5f78d..a746ac3 100644 --- a/.trellis/spec/web/index.md +++ b/.trellis/spec/web/index.md @@ -61,6 +61,13 @@ Login and public marketing/legal pages are not part of the authenticated app she - Mobile sidebar must be reachable through the menu button and must not hide the page content permanently. - Public header mobile navigation must expose feature, pricing, about, login, and language switching. +### Mobile Guided Overlays + +- Keep one dimming strategy per viewport. Do not combine a full-screen dark overlay with a spotlight element that also uses an oversized outer shadow on the same mobile viewport. +- Mobile spotlight targets should fit inside the phone viewport. If a desktop tutorial highlights a tall panel, use a smaller mobile-only target such as the rows or controls that the step actually explains. +- Tooltip placement and arrow direction must match: a tooltip above the target uses a bottom arrow pointing down; a tooltip below the target uses a top arrow pointing up. +- When the app shell owns scrolling, compute mobile overlay coordinates relative to the page component host and visible scroll container, not the document body. + ## i18n Rules - Supported locales: `zh`, `zh_Hant`, `en`. diff --git a/.trellis/tasks/05-10-fix-mobile-divination-tutorial-overlay/check.jsonl b/.trellis/tasks/05-10-fix-mobile-divination-tutorial-overlay/check.jsonl new file mode 100644 index 0000000..1c78216 --- /dev/null +++ b/.trellis/tasks/05-10-fix-mobile-divination-tutorial-overlay/check.jsonl @@ -0,0 +1 @@ +{"file": ".trellis/spec/web/index.md", "reason": "Verify mobile overlay behavior and web layout constraints."} diff --git a/.trellis/tasks/05-10-fix-mobile-divination-tutorial-overlay/implement.jsonl b/.trellis/tasks/05-10-fix-mobile-divination-tutorial-overlay/implement.jsonl new file mode 100644 index 0000000..a524957 --- /dev/null +++ b/.trellis/tasks/05-10-fix-mobile-divination-tutorial-overlay/implement.jsonl @@ -0,0 +1 @@ +{"file": ".trellis/spec/web/index.md", "reason": "Web authenticated app layout, responsive behavior, and mobile guided overlay rules."} diff --git a/.trellis/tasks/05-10-fix-mobile-divination-tutorial-overlay/prd.md b/.trellis/tasks/05-10-fix-mobile-divination-tutorial-overlay/prd.md new file mode 100644 index 0000000..5041cb4 --- /dev/null +++ b/.trellis/tasks/05-10-fix-mobile-divination-tutorial-overlay/prd.md @@ -0,0 +1,53 @@ +# Fix mobile divination tutorial overlay + +## Goal + +Fix the mobile web tutorial overlay on manual and auto divination pages so spotlight targets, tooltip placement, arrow direction, and overlay shadow match the intended mobile UX. + +## What I already know + +* User reports mobile tutorial has an overly large dark/shadowed area. +* Step 3 should highlight the six yao area, not only the coin area. If the full coin + yao area is too large, highlight only the six yao rows and exclude the coins. +* Step 4 tooltip arrow should point down toward the spotlighted "start divination" button. +* Step 4 tooltip currently overlaps the spotlight region. +* Affected files are `web/src/components/ManualDivinationPage.tsx` and `web/src/components/AutoDivinationPage.tsx`. + +## Assumptions + +* Keep desktop behavior visually equivalent unless the existing code already uses a different desktop target. +* Mobile behavior is the priority for this task. +* No backend or protocol changes are needed. + +## Requirements + +* Mobile step 3 highlights the six yao rows/panel area and excludes the coin selector. +* Mobile step 4 places the tooltip above the submit/start button with enough gap to avoid overlap. +* Mobile step 4 arrow points downward toward the spotlighted button. +* Remove the excessive/double-shadow appearance caused by overlapping dimming strategies. +* Apply the same behavior to manual and auto divination tutorial overlays. + +## Acceptance Criteria + +* [x] In mobile viewport, manual step 3 spotlight excludes the coin selector. +* [x] In mobile viewport, auto step 3 spotlight excludes the coin selector. +* [x] In mobile viewport, manual step 4 tooltip does not overlap the highlighted submit button and arrow points down. +* [x] In mobile viewport, auto step 4 tooltip does not overlap the highlighted submit button and arrow points down. +* [x] Overlay dimming is visually consistent without large unintended dark blocks. + +## Definition of Done + +* Local browser verification at a phone-sized viewport. +* `git diff --check` passes. +* Build/typecheck status documented if blocked by existing project configuration. + +## Out of Scope + +* Redesigning the tutorial copy. +* Changing tutorial persistence/profile settings behavior. +* Backend auth/session changes. + +## Technical Notes + +* Web spec applies: `.trellis/spec/web/index.md`. +* Existing mobile threshold uses `window.innerWidth < 1280`. +* App shell scroll container is the main vertical scrolling region. diff --git a/.trellis/tasks/05-10-fix-mobile-divination-tutorial-overlay/task.json b/.trellis/tasks/05-10-fix-mobile-divination-tutorial-overlay/task.json new file mode 100644 index 0000000..d8b47da --- /dev/null +++ b/.trellis/tasks/05-10-fix-mobile-divination-tutorial-overlay/task.json @@ -0,0 +1,26 @@ +{ + "id": "fix-mobile-divination-tutorial-overlay", + "name": "fix-mobile-divination-tutorial-overlay", + "title": "Fix mobile divination tutorial overlay", + "description": "", + "status": "in_progress", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "zl-q", + "assignee": "zl-q", + "createdAt": "2026-05-10", + "completedAt": null, + "branch": null, + "base_branch": "dev", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/web/src/components/AutoDivinationPage.tsx b/web/src/components/AutoDivinationPage.tsx index e83b457..498451f 100644 --- a/web/src/components/AutoDivinationPage.tsx +++ b/web/src/components/AutoDivinationPage.tsx @@ -200,6 +200,7 @@ export default function AutoDivinationPage({ locale, divination: d }: Props) { const coinsAreaRef = useRef(null); const timePanelRef = useRef(null); const yaoPanelRef = useRef(null); + const yaoRowsRef = useRef(null); const submitBtnRef = useRef(null); const scrollContainerRef = useRef(null); const [spotlightRect, setSpotlightRect] = useState<{ left: number; top: number; width: number; height: number } | null>(null); @@ -253,12 +254,17 @@ export default function AutoDivinationPage({ locale, divination: d }: Props) { return; } - const targetRef = [coinsAreaRef, timePanelRef, yaoPanelRef, submitBtnRef][guideStep]; + const mobileGuideTargets = [coinsAreaRef, timePanelRef, yaoRowsRef, submitBtnRef]; + const desktopGuideTargets = [coinsAreaRef, timePanelRef, yaoPanelRef, submitBtnRef]; + const targetRef = (isMobile ? mobileGuideTargets : desktopGuideTargets)[guideStep]; if (!targetRef?.current) return; const tooltipWidth = 320; - const tooltipHeight = 180; - const gap = 16; + const tooltipHeight = isMobile ? 220 : 180; + const gap = isMobile ? 24 : 16; + + const overlayHost = scrollContainerRef.current; + if (!overlayHost) return; const scrollContainer = scrollContainerRef.current?.parentElement?.parentElement as HTMLElement | null; if (!scrollContainer) return; @@ -266,10 +272,9 @@ export default function AutoDivinationPage({ locale, divination: d }: Props) { const isInitialOpen = prevGuideStepRef.current === null; if (isMobile) { - const containerRect = scrollContainer.getBoundingClientRect(); const elementRect = targetRef.current.getBoundingClientRect(); - const elementLeft = elementRect.left - containerRect.left; + const containerRect = scrollContainer.getBoundingClientRect(); const elementTop = elementRect.top - containerRect.top + scrollContainer.scrollTop; const elementWidth = elementRect.width; const elementHeight = elementRect.height; @@ -278,22 +283,37 @@ export default function AutoDivinationPage({ locale, divination: d }: Props) { scrollContainer.scrollTop = 0; } - const scrollTopNeeded = Math.max(0, elementTop - 20); - scrollContainer.scrollTo({ top: scrollTopNeeded, behavior: 'smooth' }); + const scrollTopNeeded = Math.max( + 0, + guideStep === 3 ? elementTop - tooltipHeight - gap - 32 : elementTop - 20, + ); + scrollContainer.scrollTo({ top: scrollTopNeeded, behavior: 'auto' }); requestAnimationFrame(() => { if (!targetRef.current) return; const newElementRect = targetRef.current.getBoundingClientRect(); - const newContainerRect = scrollContainer.getBoundingClientRect(); + const hostRect = overlayHost.getBoundingClientRect(); + const visibleContainerRect = scrollContainer.getBoundingClientRect(); + const visibleTop = visibleContainerRect.top - hostRect.top; + const visibleBottom = visibleTop + visibleContainerRect.height; - const spotlightLeft = newElementRect.left - newContainerRect.left; - const spotlightTop = newElementRect.top - newContainerRect.top; + const spotlightLeft = newElementRect.left - hostRect.left; + const spotlightTop = newElementRect.top - hostRect.top; const tooltipLeft = Math.max(16, Math.min( - (newElementRect.left + newElementRect.right - tooltipWidth) / 2 - newContainerRect.left, - containerRect.width - tooltipWidth - 16 + (newElementRect.left + newElementRect.right - tooltipWidth) / 2 - hostRect.left, + hostRect.width - tooltipWidth - 16 )); - const tooltipTop = spotlightTop + elementHeight + gap; + let tooltipTop = spotlightTop + elementHeight + gap; + let side: 'bottom' | 'top' = 'bottom'; + if (guideStep === 3) { + tooltipTop = Math.max(visibleTop + 16, spotlightTop - tooltipHeight - gap); + side = 'top'; + } + if (tooltipTop + tooltipHeight > visibleBottom) { + tooltipTop = Math.max(visibleTop + 16, spotlightTop - tooltipHeight - gap); + side = 'top'; + } setSpotlightRect({ left: spotlightLeft, @@ -302,7 +322,7 @@ export default function AutoDivinationPage({ locale, divination: d }: Props) { height: elementHeight }); setTooltipPos({ left: tooltipLeft, top: tooltipTop }); - setTooltipSide('bottom'); + setTooltipSide(side); }); prevGuideStepRef.current = guideStep; @@ -495,7 +515,7 @@ export default function AutoDivinationPage({ locale, divination: d }: Props) { {/* Six yao rows */} -
+
{[5, 4, 3, 2, 1, 0].map((index) => { const result = yaoResults[index]; const isBeingShaken = isShaking && currentShakingYao === index; @@ -597,11 +617,11 @@ export default function AutoDivinationPage({ locale, divination: d }: Props) { {guideOpen && guide && spotlightRect && ( <>
closeGuide()} />
closeGuide()} /> @@ -631,7 +651,7 @@ export default function AutoDivinationPage({ locale, divination: d }: Props) { className={`absolute h-3 w-3 rotate-45 bg-slate-950 ${ tooltipSide === 'right' ? '-left-1.5 top-6' : tooltipSide === 'left' ? '-right-1.5 top-6' : - tooltipSide === 'top' ? '-bottom-1.5 left-6' : + tooltipSide === 'top' ? '-bottom-1.5 left-1/2 -translate-x-1/2' : '-top-1.5 left-6' }`} /> diff --git a/web/src/components/ManualDivinationPage.tsx b/web/src/components/ManualDivinationPage.tsx index 1425612..949f873 100644 --- a/web/src/components/ManualDivinationPage.tsx +++ b/web/src/components/ManualDivinationPage.tsx @@ -209,6 +209,7 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) { const coinsAreaRef = useRef(null); const timePanelRef = useRef(null); const yaoPanelRef = useRef(null); + const yaoRowsRef = useRef(null); const submitBtnRef = useRef(null); const scrollContainerRef = useRef(null); const [spotlightRect, setSpotlightRect] = useState<{ left: number; top: number; width: number; height: number } | null>(null); @@ -269,12 +270,17 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) { return; } - const targetRef = [coinsAreaRef, timePanelRef, yaoPanelRef, submitBtnRef][guideStep]; + const mobileGuideTargets = [coinsAreaRef, timePanelRef, yaoRowsRef, submitBtnRef]; + const desktopGuideTargets = [coinsAreaRef, timePanelRef, yaoPanelRef, submitBtnRef]; + const targetRef = (isMobile ? mobileGuideTargets : desktopGuideTargets)[guideStep]; if (!targetRef?.current) return; const tooltipWidth = 320; - const tooltipHeight = 180; - const gap = 16; + const tooltipHeight = isMobile ? 220 : 180; + const gap = isMobile ? 24 : 16; + + const overlayHost = scrollContainerRef.current; + if (!overlayHost) return; // Get scroll container - it's the main element inside AppShell const scrollContainer = scrollContainerRef.current?.parentElement?.parentElement as HTMLElement | null; @@ -284,12 +290,10 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) { // ===== MOBILE: Absolute positioning relative to scroll container ===== if (isMobile) { - // Calculate element's offset relative to scroll container - const containerRect = scrollContainer.getBoundingClientRect(); const elementRect = targetRef.current.getBoundingClientRect(); // Element position relative to scroll container (accounts for current scroll) - const elementLeft = elementRect.left - containerRect.left; + const containerRect = scrollContainer.getBoundingClientRect(); const elementTop = elementRect.top - containerRect.top + scrollContainer.scrollTop; const elementWidth = elementRect.width; const elementHeight = elementRect.height; @@ -299,30 +303,42 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) { scrollContainer.scrollTop = 0; } - // Calculate where we need to scroll to make both spotlight and tooltip visible - // Spotlight should be at top portion, tooltip below it - const totalHeight = elementHeight + gap + tooltipHeight; - const scrollTopNeeded = Math.max(0, elementTop - 20); // 20px margin above spotlight + // Calculate where we need to scroll to make the spotlight visible. + const scrollTopNeeded = Math.max( + 0, + guideStep === 3 ? elementTop - tooltipHeight - gap - 32 : elementTop - 20, + ); - // Smooth scroll to position - scrollContainer.scrollTo({ top: scrollTopNeeded, behavior: 'smooth' }); + scrollContainer.scrollTo({ top: scrollTopNeeded, behavior: 'auto' }); - // Use requestAnimationFrame to ensure scroll has started before calculating final position + // Use requestAnimationFrame to calculate after the scroll position has updated. requestAnimationFrame(() => { + if (!targetRef.current) return; // Recalculate element position after scroll setup - const newElementRect = targetRef.current!.getBoundingClientRect(); - const newContainerRect = scrollContainer.getBoundingClientRect(); + const newElementRect = targetRef.current.getBoundingClientRect(); + const hostRect = overlayHost.getBoundingClientRect(); + const visibleContainerRect = scrollContainer.getBoundingClientRect(); + const visibleTop = visibleContainerRect.top - hostRect.top; + const visibleBottom = visibleTop + visibleContainerRect.height; - // Position relative to container (for absolute positioning) - const spotlightLeft = newElementRect.left - newContainerRect.left; - const spotlightTop = newElementRect.top - newContainerRect.top; + // Position relative to this component because the mobile overlay is absolute. + const spotlightLeft = newElementRect.left - hostRect.left; + const spotlightTop = newElementRect.top - hostRect.top; - // Tooltip goes below the element const tooltipLeft = Math.max(16, Math.min( - (newElementRect.left + newElementRect.right - tooltipWidth) / 2 - newContainerRect.left, - containerRect.width - tooltipWidth - 16 + (newElementRect.left + newElementRect.right - tooltipWidth) / 2 - hostRect.left, + hostRect.width - tooltipWidth - 16 )); - const tooltipTop = spotlightTop + elementHeight + gap; + let tooltipTop = spotlightTop + elementHeight + gap; + let side: 'bottom' | 'top' = 'bottom'; + if (guideStep === 3) { + tooltipTop = Math.max(visibleTop + 16, spotlightTop - tooltipHeight - gap); + side = 'top'; + } + if (tooltipTop + tooltipHeight > visibleBottom) { + tooltipTop = Math.max(visibleTop + 16, spotlightTop - tooltipHeight - gap); + side = 'top'; + } setSpotlightRect({ left: spotlightLeft, @@ -331,7 +347,7 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) { height: elementHeight }); setTooltipPos({ left: tooltipLeft, top: tooltipTop }); - setTooltipSide('bottom'); + setTooltipSide(side); }); prevGuideStepRef.current = guideStep; @@ -513,7 +529,7 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) { {progress} / {TOTAL_YAO_COUNT}
-
+
{[5, 4, 3, 2, 1, 0].map((index) => { const result = yaoResults[index]; // Only show "active" highlight when not in editing mode @@ -598,12 +614,12 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) { <> {/* Dark overlay - fixed for desktop, covers viewport */}
closeGuide()} /> {/* Mobile dark overlay - positioned within scroll container */}
closeGuide()} /> @@ -636,7 +652,7 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) { className={`absolute h-3 w-3 rotate-45 bg-slate-950 ${ tooltipSide === 'right' ? '-left-1.5 top-6' : tooltipSide === 'left' ? '-right-1.5 top-6' : - tooltipSide === 'top' ? '-bottom-1.5 left-6' : + tooltipSide === 'top' ? '-bottom-1.5 left-1/2 -translate-x-1/2' : '-top-1.5 left-6' }`} /> diff --git a/web/src/lib/auth.ts b/web/src/lib/auth.ts index 1d221dc..b1803b4 100644 --- a/web/src/lib/auth.ts +++ b/web/src/lib/auth.ts @@ -89,6 +89,8 @@ export function isTokenExpired(): boolean { return auth.expires_at - 60_000 < Date.now(); } +let refreshPromise: Promise | null = null; + // --- Helpers --- function toAuthData(response: SessionResponse): AuthData { @@ -144,7 +146,7 @@ export async function loginWithEmail( return data; } -export async function refreshAccessToken(): Promise { +async function doRefreshAccessToken(): Promise { const auth = getAuth(); if (!auth?.refresh_token) { clearAuth(); @@ -165,6 +167,17 @@ export async function refreshAccessToken(): Promise { return data; } +export async function refreshAccessToken(): Promise { + if (refreshPromise) return refreshPromise; + + refreshPromise = doRefreshAccessToken(); + try { + return await refreshPromise; + } finally { + refreshPromise = null; + } +} + export async function logout(): Promise { const auth = getAuth(); try {