Files
wechat_wc/docs/superpowers/plans/2026-08-08-home-scroll-top-mask.md
T

10 KiB
Raw Blame History

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 01 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:

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:

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:

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:

node --test tools/homeTopMask.test.mjs

Expected: PASS, 4 passing tests.

  • Step 5: Commit only this task
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:

import { useState } from 'react'

to:

import { useEffect, useRef, useState } from 'react'

Add this import after the existing local imports:

import { getTopMaskProgress } from '../../utils/homeTopMask'

Inside the Index component, immediately after:

const [searchKey, setSearchKey] = useState('')

add:

  const [topMaskOpacity, setTopMaskOpacity] = useState(0)
  const titleMetricsRef = useRef({ startY: 0, rangeY: 48, ready: false })
  const scrollFrameRef = useRef<number | null>(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 <ThemedPageMeta /> and before <View className='index-page'>, add:

        <View
          className='home-top-mask'
          style={{
            height: `${Math.max(safe.menuButtonTop + safe.menuButtonHeight + 18, safe.statusBarHeight + 52)}px`,
            opacity: topMaskOpacity
          }}
        />
  • Step 2: Add mask styles

Append these styles to src/pages/index/index.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:

node --test tools/homeTopMask.test.mjs

Expected: PASS, 4 passing tests.

  • Step 4: Run TypeScript/build verification

Run:

npx tsc --noEmit

Expected: exit code 0, no TypeScript errors.

Then run:

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
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.