docs: add home top mask implementation plan
This commit is contained in:
@@ -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<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.
|
||||
Reference in New Issue
Block a user