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