From 9ead2737665d05d5843508bd7bf16c7bf4d1a4a8 Mon Sep 17 00:00:00 2001 From: obroccolio Date: Sat, 8 Aug 2026 01:19:22 +0800 Subject: [PATCH] docs: add home top mask implementation plan --- .../plans/2026-08-08-home-scroll-top-mask.md | 333 ++++++++++++++++++ 1 file changed, 333 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-08-home-scroll-top-mask.md diff --git a/docs/superpowers/plans/2026-08-08-home-scroll-top-mask.md b/docs/superpowers/plans/2026-08-08-home-scroll-top-mask.md new file mode 100644 index 0000000..b366f3b --- /dev/null +++ b/docs/superpowers/plans/2026-08-08-home-scroll-top-mask.md @@ -0,0 +1,333 @@ +# Home Scroll Top Mask Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a soft top gradient mask on the home page that fades in during page scroll, improving status-bar legibility and layering without changing the existing large-title scroll behavior. + +**Architecture:** Keep the feature local to the home page. A small pure JavaScript utility calculates the `0–1` mask progress and is unit-tested with Node's built-in test runner. The home page measures the title position with `Taro.createSelectorQuery()`, listens to page scroll with `Taro.usePageScroll()`, and renders one fixed non-interactive mask above the page content. + +**Tech Stack:** Taro 3.6.31, React 18, Sass, WeChat Mini Program custom navigation, Node 22 built-in test runner. + +## Global Constraints + +- Only modify the home page feature: `src/pages/index/index.tsx`, `src/pages/index/index.scss`, and the new home-top-mask utility/tests. +- The large title `智绘微刻` must continue scrolling naturally; no sticky title, no shrinking title, no title color animation. +- Use a soft gradient only; do not use backdrop blur. +- Mask must be non-interactive with `pointer-events: none`. +- Reuse `useSafeArea()` and `useStatusBar(resolvedTheme)`; do not hard-code device-specific status-bar heights. +- Preserve the user's existing uncommitted changes in unrelated files and in `dist/`. + +--- + +## File Structure + +- Create: `src/utils/homeTopMask.js` — pure helper that converts scroll position and title metrics into mask opacity. +- Create: `tools/homeTopMask.test.mjs` — Node unit tests for the progress helper. +- Modify: `src/pages/index/index.tsx` — measure the title, listen to page scroll, render the mask, and feed opacity/style into it. +- Modify: `src/pages/index/index.scss` — add fixed mask layout and light/dark gradient styles. + +### Task 1: Add the pure mask-progress helper with tests + +**Files:** +- Create: `src/utils/homeTopMask.js` +- Create: `tools/homeTopMask.test.mjs` + +**Interfaces:** +- Produces: + - `getTopMaskProgress({ scrollTop, startY, rangeY }) => number` + - Inputs are pixel numbers. + - Output is always a finite number between `0` and `1`. + +- [ ] **Step 1: Write the failing test** + +Create `tools/homeTopMask.test.mjs` with: + +```js +import test from 'node:test' +import assert from 'node:assert/strict' +import { getTopMaskProgress } from '../src/utils/homeTopMask.js' + +test('returns 0 before the title reaches the status-bar area', () => { + assert.equal( + getTopMaskProgress({ scrollTop: 30, startY: 80, rangeY: 40 }), + 0 + ) +}) + +test('ramps linearly through the transition range', () => { + assert.equal( + getTopMaskProgress({ scrollTop: 100, startY: 80, rangeY: 40 }), + 0.5 + ) +}) + +test('returns 1 after the full transition range', () => { + assert.equal( + getTopMaskProgress({ scrollTop: 180, startY: 80, rangeY: 40 }), + 1 + ) +}) + +test('clamps invalid values into a stable progress range', () => { + assert.equal(getTopMaskProgress({ scrollTop: -10, startY: 80, rangeY: 40 }), 0) + assert.equal(getTopMaskProgress({ scrollTop: 90, startY: 80, rangeY: 0 }), 1) + assert.equal(getTopMaskProgress({ scrollTop: Number.NaN, startY: 80, rangeY: 40 }), 0) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +node --test tools/homeTopMask.test.mjs +``` + +Expected: FAIL because `../src/utils/homeTopMask.js` does not exist. + +- [ ] **Step 3: Write minimal implementation** + +Create `src/utils/homeTopMask.js` with: + +```js +export function getTopMaskProgress({ scrollTop, startY, rangeY }) { + const safeScrollTop = Number.isFinite(scrollTop) ? scrollTop : 0 + const start = Math.max(0, Number.isFinite(startY) ? startY : 0) + const range = Math.max(1, Number.isFinite(rangeY) ? rangeY : 1) + + if (safeScrollTop <= start) { + return 0 + } + + return Math.min(1, (safeScrollTop - start) / range) +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: + +```bash +node --test tools/homeTopMask.test.mjs +``` + +Expected: PASS, 4 passing tests. + +- [ ] **Step 5: Commit only this task** + +```bash +git add src/utils/homeTopMask.js tools/homeTopMask.test.mjs +git commit -m "test: add home top mask progress helper" +``` + +--- + +### Task 2: Add the fixed gradient mask to the home page + +**Files:** +- Modify: `src/pages/index/index.tsx` +- Modify: `src/pages/index/index.scss` + +**Interfaces:** +- Consumes: + - `getTopMaskProgress({ scrollTop, startY, rangeY }) => number` from `src/utils/homeTopMask.js`. + - `safe.statusBarHeight`, `safe.menuButtonTop`, and `safe.menuButtonHeight` from existing `useSafeArea()`. +- Produces: + - A `View.home-top-mask` element on the home page. + - Inline `height` and `opacity` styles for that element. + +- [ ] **Step 1: Update the home page component** + +In `src/pages/index/index.tsx`, change the React import from: + +```ts +import { useState } from 'react' +``` + +to: + +```ts +import { useEffect, useRef, useState } from 'react' +``` + +Add this import after the existing local imports: + +```ts +import { getTopMaskProgress } from '../../utils/homeTopMask' +``` + +Inside the `Index` component, immediately after: + +```ts +const [searchKey, setSearchKey] = useState('') +``` + +add: + +```ts + const [topMaskOpacity, setTopMaskOpacity] = useState(0) + const titleMetricsRef = useRef({ startY: 0, rangeY: 48, ready: false }) + const scrollFrameRef = useRef(null) + const latestScrollTopRef = useRef(0) + + const applyTopMaskProgress = (scrollTop: number) => { + latestScrollTopRef.current = scrollTop + if (scrollFrameRef.current !== null) { + return + } + + scrollFrameRef.current = (typeof requestAnimationFrame === 'function' + ? requestAnimationFrame + : setTimeout + )(() => { + scrollFrameRef.current = null + const { startY, rangeY } = titleMetricsRef.current + setTopMaskOpacity(getTopMaskProgress({ scrollTop: latestScrollTopRef.current, startY, rangeY })) + }, 16) as unknown as number + } + + useEffect(() => { + const measureTitle = () => { + Taro.createSelectorQuery() + .select('.home-header') + .boundingClientRect((rect: { top?: number; bottom?: number; height?: number } | null) => { + if (!rect || typeof rect.bottom !== 'number' || typeof rect.height !== 'number') { + const fallbackRange = Math.max(48, safe.headerPaddingTop * 0.45) + titleMetricsRef.current = { + startY: Math.max(0, safe.headerPaddingTop - safe.statusBarHeight), + rangeY: fallbackRange, + ready: true + } + applyTopMaskProgress(latestScrollTopRef.current) + return + } + + const startY = Math.max(0, rect.bottom - safe.statusBarHeight - 8) + const rangeY = Math.max(32, rect.height * 0.7) + titleMetricsRef.current = { startY, rangeY, ready: true } + applyTopMaskProgress(latestScrollTopRef.current) + }) + .exec() + } + + Taro.nextTick(() => { + setTimeout(measureTitle, 80) + }) + + return () => { + if (scrollFrameRef.current !== null && typeof cancelAnimationFrame === 'function') { + cancelAnimationFrame(scrollFrameRef.current) + } + scrollFrameRef.current = null + } + }, [safe.headerPaddingTop, safe.statusBarHeight]) + + Taro.usePageScroll((e) => { + applyTopMaskProgress(e.scrollTop) + }) +``` + +In the JSX, immediately after `` and before ``, add: + +```tsx + +``` + +- [ ] **Step 2: Add mask styles** + +Append these styles to `src/pages/index/index.scss`: + +```scss +.home-top-mask { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 40; + pointer-events: none; + transform: translateZ(0); + will-change: opacity; +} + +.theme-light .home-top-mask { + background: linear-gradient( + to bottom, + rgba(250, 247, 242, 0.98) 0%, + rgba(250, 247, 242, 0.92) 38%, + rgba(250, 247, 242, 0.48) 70%, + rgba(250, 247, 242, 0) 100% + ); +} + +.theme-dark .home-top-mask { + background: linear-gradient( + to bottom, + rgba(25, 25, 25, 0.98) 0%, + rgba(25, 25, 25, 0.92) 38%, + rgba(25, 25, 25, 0.48) 70%, + rgba(25, 25, 25, 0) 100% + ); +} +``` + +- [ ] **Step 3: Run the unit test** + +Run: + +```bash +node --test tools/homeTopMask.test.mjs +``` + +Expected: PASS, 4 passing tests. + +- [ ] **Step 4: Run TypeScript/build verification** + +Run: + +```bash +npx tsc --noEmit +``` + +Expected: exit code 0, no TypeScript errors. + +Then run: + +```bash +npm run build:weapp +``` + +Expected: exit code 0. The Taro build should compile `src/pages/index/index.tsx` and `src/pages/index/index.scss` into `dist/`. + +- [ ] **Step 5: Manual Mini Program verification checklist** + +Use WeChat DevTools to open `/Users/broccoli/Project/wechat_wc/dist` and verify: + +1. At scroll top, the top mask is invisible. +2. Scrolling up around the `智绘微刻` title position makes the soft mask fade in. +3. After continuing to scroll, the mask reaches full strength. +4. Pulling back to top makes the mask fade out. +5. The search box and cards remain clickable through the mask. +6. Light and dark themes both show a clear status-bar area. +7. The title is not sticky, does not shrink, and does not change color. + +- [ ] **Step 6: Commit only source files for this task** + +```bash +git add src/pages/index/index.tsx src/pages/index/index.scss +git commit -m "feat: add home scroll top mask" +``` + +--- + +## Self-Review Notes + +- Spec coverage: The plan implements page scroll listening, title measurement, a fixed soft gradient mask, safe-area reuse, light/dark support, no blur, no title transformation, and non-interactivity. +- Scope: No other pages change and no reusable component is introduced. +- Test strategy: The non-visual progress calculation is unit-tested first; Taro integration is verified by TypeScript/build plus the manual Mini Program checklist. +- Existing worktree: Existing unrelated modifications and generated `dist/` changes must not be staged unless created by this implementation.