fix(webui): 手势与横向滚动分家 + 纵向滚动边缘淡出

用户给了两条具体信息(这比我自己猜五轮都管用):
「横向滚动条会与切换视图的手势冲突,我觉得周视图需要卡严条件,
  同时我说的其他硬截断是对应内容项上下滑动会直接被切断」。

## ① 手势卡严(周视图)

冲突是真的:周视图窄屏下必须横向滚(`overflow-auto` + `min-w-[36rem]`),
而我又给整页加了左右滑动翻页 ⇒ 同一次横滑既想滚又想翻,两边都不好用。

规则定死:**手势从可横向滚动的区域里起手,就归滚动条,完全不参与翻页判断**
(触点沿祖先链查找 `overflow-x: auto/scroll` 且真的能滚的容器)。
想翻页就从别处滑(例如上方标题栏)。

## ② 纵向滚动边缘淡出

滚动条本身是"一刀切":卡片滚到边缘被硬生生截断 —— 这就是用户说的"直接被切断"。
给纵向滚动容器(`.overflow-y-auto`)加 12px 上下渐隐遮罩,切得有交代。

只作用在**纵向**容器:横向滚动有自己的滚动条,加纵向遮罩会跟它打架。

## 过程记录(值得记)

这两条改动**第一次没有生效**,因为部署失败了:`/tmp` 是 tmpfs 且已 100% 满,
而部署脚本把构建产物写到硬编码的 `/tmp/agentmail-gateway-build-*` ⇒
`no space left on device`。我差点把"旧构建上的测量结果"当成"改动无效"。
清理后(清掉我自己的探针脚本/截图/旧构建,约 950MB)部署成功。
⚠️ `/tmp` 现在仍占 91%(`gocache` 4.5G 等不全是我的),**下次部署可能还会撞上**;
脚本改成尊重 `TMPDIR` 才是根治(未做)。
This commit is contained in:
2026-09-14 14:35:43 +08:00
parent c9717daa13
commit 1717863c87
5 changed files with 197 additions and 14 deletions

View File

@ -150,9 +150,39 @@ export default function CalendarView() {
* 那是移动端最容易犯的手势错误);同时要求时间 < 600ms避免"慢慢拖"也翻页。
*/
const touch = useRef<{ x: number; y: number; t: number } | null>(null);
/**
* 触点是否落在**可横向滚动**的区域里(周视图就是:`overflow-auto` + `min-w-[36rem]`)。
*
* 用户2026-09-14「横向滚动条会与切换视图的手势冲突我觉得周视图需要卡严条件」。
*
* 冲突是真的:周视图在窄屏下必须横向滚,而我又给整页加了左右滑动翻页 ⇒
* 同一次横滑既想滚又想翻,结果两边都不好用。规则定死:**手势从可横向滚动的
* 区域里开始,就归滚动条,不翻页**。想要翻页就从别处(例如上方的标题栏)滑。
*/
const startsInHorizontalScroller = (target: EventTarget | null, root: EventTarget | null) => {
let el = target as HTMLElement | null;
while (el && el !== root) {
const cs = getComputedStyle(el);
if (
(cs.overflowX === 'auto' || cs.overflowX === 'scroll') &&
el.scrollWidth > el.clientWidth + 4
) {
return true;
}
el = el.parentElement;
}
return false;
};
const onTouchStart = (e: React.TouchEvent) => {
const t = e.touches[0];
if (!t) return;
if (startsInHorizontalScroller(e.target, e.currentTarget)) {
// 卡严条件:这一次手势完全不参与翻页判断
touch.current = null;
return;
}
touch.current = { x: t.clientX, y: t.clientY, t: Date.now() };
};
const onTouchEnd = (e: React.TouchEvent) => {

View File

@ -1354,3 +1354,31 @@ html[data-bg='on'] .glass-card:hover {
flex: 0 0 auto;
width: 320px;
}
/*
* ★ 滚动容器的边缘淡出2026-09-14 用户:「内容项上下滑动会直接被切断」)。
*
* 滚动条本身是"一刀切":卡片滚到边缘就被硬生生截断。给滚动容器加一层
* 上下渐隐的遮罩,切断处变成渐隐 —— 切得有交代,观感也不再像 bug。
*
* 只作用在**纵向**滚动容器(`.overflow-y-auto`):周视图那种**横向**滚动
* 有自己的横向滚动条,加纵向遮罩会跟它打架(用户刚提过手势冲突那件事)。
* 上下各 12px 与列表内边距10px接近静止时几乎看不出滚动时才起作用。
*/
.overflow-y-auto {
-webkit-mask-image: linear-gradient(
to bottom,
transparent 0,
#000 12px,
#000 calc(100% - 12px),
transparent 100%
);
mask-image: linear-gradient(
to bottom,
transparent 0,
#000 12px,
#000 calc(100% - 12px),
transparent 100%
);
}

View File

@ -423,3 +423,37 @@ test('判据自检:预设清单少一档必须判红', () => {
const trimmed = webIds.slice(0, -1);
assert.notDeepEqual(trimmed, W.PRESET_IDS, '自检:裁掉一档后必须与实现不一致(否则这条判据没有分辨力)');
});
test('★ 背景画出来了还不够:每个页面要**让出**页面底,否则壁纸全被盖住', () => {
/*
* 这一条是"渲染"这句话的另一半。只把壁纸铺在最底层、而每个页面自己又刷一层
* **不透明**的系统页面底,壁纸就等于没画(用户看到的仍然是纯色页面)。
* WebUI 侧的原话:「页面底 → 完全透明,让出背景;不改 27 个组件的 class
* 逐个加 class 必然漏(漏掉的那块就是一张不透明卡片浮在背景上)」。
*
* 所以判据钉的是"没有一处页面底还在用不透明的系统页面底"——
* 漏掉任何一个页面,就是那一页看不到壁纸。
*/
const main = read('pages/MainPage.ets');
const opaqueRoots = [...main.matchAll(/\.backgroundColor\(Theme\.pageBg\)/g)].length;
assert.equal(opaqueRoots, 0,
`还有 ${opaqueRoots} 处页面底用不透明的 Theme.pageBg —— 那几页看不到壁纸`);
const yielded = [...main.matchAll(/\.backgroundColor\(this\.bgActive \? Color\.Transparent : Theme\.pageBg\)/g)].length;
assert.ok(yielded >= 5, `要让出页面底的页面至少 5 个(通信/联系人 + 三个 pane实际 ${yielded}`);
// 每个页面都要**收到**这个开关:漏传 = 该页恒为不透明(等于没有让出)
for (const comp of ['CommPage', 'ContactsTab']) {
assert.match(main, new RegExp(`${comp}\\(\\{ bgActive: this\\.bgActive \\}\\)`), `主界面要把 bgActive 传给 ${comp}`);
}
for (const pane of ['InboxTab', 'SentTab', 'PermissionTab']) {
assert.match(main, new RegExp(`${pane}\\(\\{ bgActive: this\\.bgActive \\}\\)`), `通信页要把 bgActive 传给 ${pane}`);
}
// 开关必须由**背景计划**驱动(不是写死的 true/false
assert.match(main, /this\.bgActive = this\.bgPlan\.kind !== 'none';/, 'bgActive 要由背景计划决定');
// 每个组件都要声明这个 @Prop漏一个就编译不过但判据先钉住意图
for (const comp of ['CommPage', 'ContactsTab', 'InboxTab', 'SentTab', 'PermissionTab']) {
const at = main.indexOf(`struct ${comp} {`);
const head = main.slice(at, at + 400);
assert.match(head, /@Prop bgActive: boolean = false;/, `${comp} 要声明 @Prop bgActive`);
}
});

View File

@ -53,6 +53,8 @@ const INBOX_PAGE_SIZE: number = 50;
@Component
struct InboxTab {
/** 背景是否开启开着就让出页面底WebUI 的做法是页面底完全透明) */
@Prop bgActive: boolean = false;
@State mails: MailSummary[] = [];
/** 按会话折叠后的列表(单封的组平铺渲染) */
@State groups: SessionGroup[] = [];
@ -409,7 +411,7 @@ struct InboxTab {
.backgroundColor(Theme.surface)
}
.width('100%').height('100%')
.backgroundColor(Theme.pageBg)
.backgroundColor(this.bgActive ? Color.Transparent : Theme.pageBg)
}
/*
@ -576,6 +578,8 @@ struct InboxTab {
@Component
struct SentTab {
/** 背景是否开启开着就让出页面底WebUI 的做法是页面底完全透明) */
@Prop bgActive: boolean = false;
@State groups: SessionGroup[] = [];
@State expandedKeys: string[] = [];
@State loading: boolean = false;
@ -778,7 +782,7 @@ struct SentTab {
}
}
.width('100%').height('100%')
.backgroundColor(Theme.pageBg)
.backgroundColor(this.bgActive ? Color.Transparent : Theme.pageBg)
}
}
@ -797,6 +801,8 @@ struct SentTab {
@Component
struct PermissionTab {
/** 背景是否开启开着就让出页面底WebUI 的做法是页面底完全透明) */
@Prop bgActive: boolean = false;
@State requests: PermissionRequest[] = [];
@State loading: boolean = false;
@State error: string = '';
@ -1008,7 +1014,7 @@ struct PermissionTab {
}
}
.width('100%').height('100%')
.backgroundColor(Theme.pageBg)
.backgroundColor(this.bgActive ? Color.Transparent : Theme.pageBg)
}
}
@ -1032,6 +1038,8 @@ struct PermissionTab {
@Component
struct CommPage {
/** 背景开启时,本页与其三个 pane 的页面底都要让出(否则壁纸全被盖住) */
@Prop bgActive: boolean = false;
@State commTab: string = 'inbox';
@State unreadCount: number = 0;
@State pendingCount: number = 0;
@ -1165,11 +1173,11 @@ struct CommPage {
this.CommTabBar()
if (this.commTab === 'sent') {
SentTab()
SentTab({ bgActive: this.bgActive })
} else if (this.commTab === 'permissions') {
PermissionTab()
PermissionTab({ bgActive: this.bgActive })
} else {
InboxTab()
InboxTab({ bgActive: this.bgActive })
}
}
.width('100%').height('100%')
@ -1188,12 +1196,13 @@ struct CommPage {
.onClick(() => { this.openCompose(); })
}
.width('100%').height('100%')
.backgroundColor(Theme.pageBg)
.backgroundColor(this.bgActive ? Color.Transparent : Theme.pageBg)
}
}
@Component
struct ContactsTab {
@Prop bgActive: boolean = false;
@State contacts: Contact[] = [];
@State loading: boolean = false;
@State error: string = '';
@ -1298,7 +1307,7 @@ struct ContactsTab {
}
}
.width('100%').height('100%')
.backgroundColor(Theme.pageBg)
.backgroundColor(this.bgActive ? Color.Transparent : Theme.pageBg)
}
/**
@ -1459,6 +1468,15 @@ struct MainPage {
* 现在补上:预设档画渐变、图片档画图 + 压暗。
*/
@State bgPlan: BackgroundPlan = new BackgroundPlan();
/**
* 背景是否开着 —— 传给每个页面,让它们把**页面底**让出来(变成透明)。
*
* 这是"背景画出来了"这句话的另一半:只把壁纸铺在最底层、而每个页面自己又刷一层
* 系统页面底(`Theme.pageBg` 是不透明的),壁纸就**全被盖住**,等于没画。
* WebUI 侧对这件事的原话:「页面底 → 完全透明,让出背景;不改 27 个组件的 class
* 逐个加 class 必然漏(漏掉的那块就是一张不透明卡片浮在背景上)」。
*/
@State bgActive: boolean = false;
@State wallpaperImage: image.PixelMap | null = null;
private gridSettings: RenderingContextSettings = new RenderingContextSettings(true);
private gridCtx: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.gridSettings);
@ -1488,6 +1506,7 @@ struct MainPage {
const snap: AppearanceSnapshot = store.current();
this.wallpaperImage = store.wallpaper;
this.bgPlan = resolveBackground(snap.bgKind, snap.bgPresetId, scrimOpacity(snap.bgDim), store.wallpaper !== null);
this.bgActive = this.bgPlan.kind !== 'none';
}
/** 一层渐变的色标:`['色', 位置]` 成对(页面才拼,纯逻辑里只存两个数组) */
@ -1619,12 +1638,12 @@ struct MainPage {
this.WallpaperLayer()
Tabs({ barPosition: BarPosition.End }) {
TabContent() {
CommPage()
CommPage({ bgActive: this.bgActive })
}
.tabBar(this.TabBarBuilder('通信', '✉️', 0))
TabContent() {
ContactsTab()
ContactsTab({ bgActive: this.bgActive })
}
.tabBar(this.TabBarBuilder('联系人', '👤', 1))
}

View File

@ -158,7 +158,7 @@ WebUI 侧 `npm test` 在 **HEAD 上就是红的**`test/background.test.mjs`
| P2a | 信息架构:「通信一项内部页签 收件箱/发件箱/授权未读红待决策橙徽标 | **点页签 → 断言落到哪个 pane**不是断言页签个数)—— 页签状态机判据已落地 §7.15 |
| P2b | 发件箱页`GET /me/mail/sent`复用列表项 | 判据接口路径行上主角是收件人空态有说明主句与 WebUI 逐字一致 |
| P3 主体完成 | 授权页`GET /permission/pending` + `POST /permission/decide` | 未决口径与 WebUI 一致 `permission_result`)✅;拒绝可填备注且备注送出 ✅;`expired` 当场说清"这次批准不会恢复原调用" ✅。**未验**真机上点同意/拒绝后状态是否"立刻变"判据只钉到"决策后重新拉列表"这一层 |
| P4 ✅(壁纸上传除外 | 主题/壁纸`/me/appearance` | 换账号外观跟随 ✅(缓存键带账号服务端无记录时以本地为准 ✅(§7.16**未做**壁纸**上传**入口需要 picker。**未验**真机渲染 |
| P4 ✅(P4c 上传除外 | 主题/壁纸`/me/appearance` | 换账号外观跟随 ✅(缓存键带账号服务端无记录时以本地为准 ✅(§7.16**预设 6 档都能画出来** ✅、图片壁纸渲染 ✅(§7.17 —— 这一版补的第一版只有数据没有画面)。**未做**P4c 上传入口。**未验**真机观感与深色档预设 |
| P5 未做 | 悬浮玻璃导航取代系统 TabBar | 模糊只由壁纸层负责列表项每项一张卡命中区 44vp现状底栏仍是系统 `Tabs`只有自绘的 tabBar builder 带了 `backgroundBlurStyle`(§7.10 |
| P6 未做 | 日历`/calendar/events` ics 导入导出 | 手势阈值与 WebUI 一致水平 40px、≥1.5× 垂直、<600ms)。**有意排序**入口与内容一起上不留空页签(§7.15 |
@ -667,6 +667,78 @@ WebUI 也接好了;鸿蒙这边此前**完全没有接** —— 主题与壁
发现方式是判据去 SDK 的枚举文件里读数比对,而不是凭印象。映射也因此搬进了纯逻辑
`colorModeValue`),从"某处有个 setColorMode 调用"变成"可判据的行为"。
**未验 / 未做**:壁纸在真机上的渲染效果(需要真机或模拟器);
**壁纸上传(选图 → `POST /me/appearance/image`)还没接** —— 需要文件选择器picker
这一期的 API 与命名都已就位,但入口没做,所以**不要**把它当成"已完成"。
**⚠️ 更正一处我说得比证据强的地方**:上一版这里写的是"壁纸在真机上的**渲染**效果未验"
听着像"已经画出来了、只是没在真机上看过"。实际情况是:**P4 第一版没有任何东西去画它** ——
`AppearanceStore` 取回了 `PixelMap`、算好了快照,但没有组件把它渲染出来,
也就是说那一版里"壁纸"只有数据没有画面。取回像素这件事是真的(提交信息没写错),
但"渲染未验"这个说法把"没做"说成了"没验"。这条更正记在这里,免得后来人以为是回归。
### 7.17 P4b预设档的画法pi 指出的**信息对等**缺口)
pi 的原话「WebUI 的背景有**预设渐变**,服务端存的是 preset 名 + 参数,
鸿蒙拿到 preset 名画得出来吗?如果只支持 `image``none`,那'换账号后外观跟随'
对预设档就是**不成立**的 —— 用户设了预设,在鸿蒙看到的是没有背景。
这是一个信息对等缺口,不是入口缺口,而且它比上传入口更容易被忽略。」
他说对了,而且当时比这更糟(见上面那条更正)。现在:
- `model/Wallpaper.ts`纯逻辑判据直接跑预设清单id / 中文标签 / 归一化)、
色板、六个预设各由哪些层叠出来、`resolveBackground()` 决定画什么;
- 页面用**系统原语**画:`radialGradient` / `linearGradient`
**网格档**CSS 的 `repeating-linear-gradient`)系统没有对应原语 → 用系统 `Canvas` 画线
(线色/间隔照抄 CSSgray-200 / 0.55 / 28理由写在模块里
- 图片档:`Image(pixelMap)` + 系统遮罩色按服务端浓度压暗;
- `image` 档但图没取回来 → **什么都不画**(画一块空白会被当成"壁纸坏了")。
**判据**`harmony-appearance` 11 → 17 条):预设 id/顺序/标签与 WebUI `PRESETS` 逐字一致;
**每个预设色值与 CSS 调色板变量逐个对照**(这类"看起来差不多"的色值最容易悄悄分叉);
色板反向检查(登记了没用的 → 红);透明必须用关键字而不是 8 位色值;
三档的 resolve 行为;页面真的画了(三种原语 + 图片 + 压暗);
以及**模糊归属的互斥形式**(见下)。
**判据抓到的真 bug**:我把 `--c-blue-200`191 219 254 = `#BFDBFE`)写成了 `#BFDCFE`
(两位字母顺序反了)—— 这正是"照 CSS 读出来比"才拦得住的一类错。
另外判据自己也有两处切片毛病(用 `indexOf('build() {')` 两头夹会跨到别的成员上),已改按行截。
### 7.18 pi 撤回的那条口径:模糊归属改成**互斥形式**
pi 撤回了他原来那句"模糊只由壁纸层负责",并说明了它的来源:那是 **WebUI 的架构结论**
——它的壁纸图层自带 `filter: blur()`,浮在它上面的面再 `backdrop-filter` 就是把同一张
糊过的底**糊第二遍**(更脏、更掉帧),所以那条规则在 WebUI 侧是空的;
而同一条 CSS 里它的**底部导航 `.narrow-nav` 是有 `backdrop-filter` 的**
因为那一条背后是**会滚动的内容**,模糊在那里有遮蔽意义。
所以正确的形式是两条性质,而不是"归谁"
1. **同一张底只许被模糊一次**
2. **模糊应出现在"背后是可变内容"的层**
套到鸿蒙:壁纸是整幅图、栏不吃壁纸,用户的模糊偏好被映射成**材质档位**
于是栏上的系统材质就是唯一一次模糊,壁纸层不再糊 —— 满足"只一次",也更符合"用系统方案"。
判据按互斥形式写:**壁纸层不许出现任何模糊/材质****导航条必须有系统材质**
将来 P5 真做悬浮玻璃条(浮在滚动内容上)时,按"背后是可变内容"这条放行第二处,
并在 `GLASS_REGISTRY` 里登记 + 说明它背后确实是滚动内容(不是又一层壁纸)。
**语义转换要记清**pi 要求写进文档,否则以后有人拿"`bg_blur=8px` 与档位对不上"当 bug 报):
WebUI 的"壁纸模糊度px"在鸿蒙变成了"**材质档次**"——**不是同一个物理量**
前者是给 CSS 图层用的半径后者是系统材质的档位Thin/Regular/Thick
映射在 `model/Appearance.ts``blurStyleFor()`,判据与 SDK 的 `BlurStyle` 成员比对。
### 7.19 两条跨端约定pi 2026-09-14 复核后确认)
- **`Theme.` 的成员名不改**pi 三条理由:判据钉的是"值来自系统 + 品牌色仍手写"
改名零收益;名字一致本身就是这个跨端词表的价值,`Theme.surface` ↔ WebUI `surface`
是同一件事149 处搬运的风险与收益不成比例)。**边界**:将来某处真需要"系统里更具体的面"
(例如 `ohos_id_color_dialog_bg`**那时新增一个成员**,而不是把已有名字改一遍。
- **可选数值字段的"缺省值"本身就是契约的一部分**WebUI 用 `dflt`、鸿蒙用类字段默认值 ——
两处默认值不一致就会**静默分叉**P4 里真踩过:`bg_dim` 缺失时一边给 12、一边给 0
跨端对齐可选数值字段时,先对齐默认值,再对齐取值。
- **页签键/顺序**`CommTab`/`TABS``WorkCard` 字段集是同一类跨端耦合 —— pi 改 `uiStore.ts`
`CommTabs.tsx` 前会先看鸿蒙这条判据,两边同时改;**不让我单方面红**。
**未做P4c**:壁纸**上传**入口(选图 → `POST /me/appearance/image`。pi 定为 P4c算 P4 范围,
不阻塞别的阶段),照 WebUI 踩过的三条做:**先压缩再上传**(手机直出照片 48MB
**失败必须给原因**(别静默失败)、**上传成功后仍以服务端为权威**`saved` 那套规则对图片同样适用)。
**未验**:预设渐变与图片壁纸在真机上的实际观感(尤其是**深色模式**下预设的表现 ——
色板现在取的是 CSS 的浅色档,深色档要不要另给一套,等真机看过再定)。