334 lines
10 KiB
Markdown
334 lines
10 KiB
Markdown
# 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<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:
|
||
|
||
```tsx
|
||
<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`:
|
||
|
||
```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.
|