跨端: 回复/转发改内联底栏 + 入场动画对称化(照鸿蒙文档纠正三处误判)

用户:「点击回复按键与新建邮件部分的动画与 webui 不一致,动画不符合鸿蒙视觉
要求」。两个问题是分开的:结构是覆盖式弹层 vs WebUI 的内联底栏;动画则是我
单方面发明的不对称过渡 + 150ms 低于鸿蒙规范下限。

## 结构:覆盖式弹层 → 底部内联条(回复 / 转发)

WebUI `MailView.tsx:410/1057` 的 `ReplyBar`/`ForwardBar` 是 `border-t` 分出的
**内联底栏**,与正文并列(正文 `flex-1 overflow-y-auto` 保持可见可滚),高度由
内容决定。我们原先是整屏遮罩 + `height('60%')` + `position({x:0,y:0})`。

三条用户可感知的差异:弹层盖住正文(写回复时看不到原文)/固定 60% 高(写一行
也占半屏)/遮罩整屏变暗。结构不用动外层 —— 原版那两处本来就是正文 Stack 的
**兄弟**(同在 `Column` 里 ⇒ 本来竖直排列),错只错在给条加了遮罩/定高/绝对定位。

## 动画

① `paneRiseIn()` / `calendarSlide()` 去 `asymmetric`,改**对称**。
   WebUI 是 `animation: rise-in 150ms … both` —— `both` 就是进出同一条关键帧。
   我原先让出场只做 `opacity` 且更短(120ms),"出现时浮上来、消失时只淡出",
   正是"与 webui 不一致"的来源。当初写不对称的理由(换窗格时两层同时半透明会
   "闪")只对**换窗格**成立,对回复框/转发条不成立 —— 我把两种场景混用了。

② `durRise` 150 → **200ms**(用户选定"折中")。WebUI 是 150(web 常规档),
   鸿蒙官方「元素淡入/位移进入」建议 **200-300ms**,150 比下限还低 25%。

## 照文档纠正三处误判(本轮的真正收获)

我为了搞清"为什么动画不播",先后编出过三个错误理论,读文档后逐条推翻:

① **不是 "NavDestination 吃掉子组件的 `.transition()`"**。
   实测:`ComposeView` 根上的 `.transition()` 一直在播。我之所以连测七八轮都报
   "没有中间帧",是因为**拿平均亮度当探针** —— 白底窗格 50% 透明叠在浅色背景上
   平均亮度几乎不变。换成**位移**探针后,立刻看到"整栏下移 300vp 且半透明"的
   中间帧。教训:**探针对被测变化不敏感时,量的是噪声**。

② **不 `customTransition` 也能做**。`NavDestination` 确实有 `customTransition`
   (API 15+),但它是**整页转场**,我们要的只是内容块的一次上浮淡入。
   (顺带记一条:`NavDestinationTransition.curve` 的类型是枚举 `Curve`,
   不收 `ICurve` —— 试过用 `curves.cubicBezierCurve` 会编译报错。)

③ **`.opacity()` 在 `NavDestination` 上是生效的**。先前判定"不生效"同样是那个
   废探针害的;换 `opacity(0)` 二元判定后整页消失,证明它一直生效。

## 连带修一个真 bug(判据抓的)

回复/转发改成内联后**失去了"弹层有固定高度"这层键盘保护** —— 官方默认
`KeyboardAvoidMode.OFFSET`(整页上移)会把贴底的「取消/发送/转发」顶出屏幕。
在 `EntryAbility` 里显式设 `RESIZE`(按剩余高度重排)。坑:`@kit.ArkUI` 与全局
作用域各有一个同名 `KeyboardAvoidMode`,**只有前者有 `RESIZE`**。

## 判据(4 条红全部结算,逐条说明为什么不是放宽)

· `harmony-nav` durRise:从"逐字等于 150"改为**区间 200-300**(钉住用户裁定,
  退回 150 与写 800 都红,已变异验证)。
· `harmony-nav` asymmetric:**反转**为"不得 asymmetric"(旧断言把上一版设计锁住,
  而 WebUI 本来就是对称的)。
· `harmony-admin` 弹层高度:原断言数的形状只属于废弃的覆盖式弹层 → 改为
  **新结构下的等价不变式**(键盘避让必须 RESIZE)。这条判据当年抓的是真 bug,
  该 bug 换了形态仍在,所以不能简单删。
· `cross-client-theme`:删掉我中途废弃留下的孤儿令牌 `riseCurveEnum`。

新增 4 条变异条目(全部 `红✓`);`mutants=52 ran=52 skipped=0`。
`files=33 checks=530 pass=530 fail=0`,`baseline=7/7✓`。
设备已验:回复/转发确为内联底栏(正文可见、`border-t` 分隔)。
This commit is contained in:
2026-09-21 02:19:30 +08:00
parent 5e4a1b616c
commit 6f1b4352cd
6 changed files with 379 additions and 180 deletions

View File

@ -734,8 +734,27 @@ export class Theme {
* 这个区分是 2026-09-18 查出来的,之前两者被当成同一个数。
*/
static readonly durBase: number = 180;
/** 面板/内容入场 —— WebUI `@keyframes rise-in` 实测 **150ms** */
static readonly durRise: number = 150;
/**
* 面板/内容入场。
*
* ★★ 2026-09-20 150 → **200**(用户:「动画不符合鸿蒙视觉要求」,选定"折中 200ms")。
*
* 两个来源**打架**,这里取了折中:
* · WebUI `@keyframes rise-in` 实测 **150ms**(本仓既有基准)
* · 鸿蒙官方动效规范(developer.huawei.com「动效属性」页):
* 「元素淡入/位移进入 **200-300ms**」+ EaseOut
* 150ms 比鸿蒙建议的**下限还短 25%** —— 在鸿蒙上确实偏"赶",
* 系统的原生页面转场普遍用 250-350ms。
*
* 取 **200ms(规范区间下限)** 而不是直接上 250ms:
* WebUI 那一侧的值是用户当年**逐个调过**的(09-14 否掉过"整屏淡入"、
* 09-15 报过"动画卡顿"),一下子拉到 250 会让两端观感差得更远。
* 200ms 两边都不冒犯,且落在鸿蒙规范区间内。
*
* ★ 这条**不是**"令牌值"而是"动画值"—— 上面那段注释讲过两者的区别,
* 别拿 `durBase`(180) 或 `durFast`(120) 来替它。
*/
static readonly durRise: number = 200;
/** 弹层入场 —— WebUI `.animate-menu-in` 实测 **140ms** */
static readonly durMenu: number = 140;
/** 日历翻月 —— WebUI `.cal-slide-next/prev` 实测 **200ms** */
@ -799,12 +818,30 @@ export class Theme {
* 见 `MainPage` 的 `calPaneOpacity` / `calPaneShift`。
*/
static paneRiseIn(): TransitionEffect {
return TransitionEffect.asymmetric(
TransitionEffect.opacity(0).combine(
TransitionEffect.translate({ y: Theme.riseInOffset })
).animation({ duration: Motion.dur(Theme.durRise), curve: Theme.easeRise }),
TransitionEffect.opacity(0).animation({ duration: Motion.dur(Theme.durFast), curve: Theme.easeRise })
);
/*
* ★★ 2026-09-20 改成**对称**(用户:「点击回复按键…动画与 webui 不一致」)。
*
* 改之前是 `asymmetric`:入场做 `opacity + translate`(150ms),
* 退场**只做 opacity**(120ms)。于是同一个"回复框"出现时会浮上来、
* 消失时却只淡出 —— **两头的动作不是同一件事**。
*
* WebUI 那边是一条 `@keyframes rise-in` 加 `both`:
* animation: rise-in 150ms cubic-bezier(0.22,0.61,0.36,1) both;
* `both` = 进出都用同一条关键帧 ⇒ 天然对称。
*
* ★ 当初为什么写成不对称:注释里记过理由 ——「ArkUI 是 if/else 换子树,
* 两层会同时半透明地叠着,同长看起来就是闪一下」。这个担心对
* **换窗格**(通信↔日历那种整块替换)成立;但**对回复框/转发条不成立** ——
* 它出现时底下没有"正在消失的另一半",而是一块一直存在的正文。
* 我把两种场景混用了同一个过渡。
*
* ⇒ 现在对称:进出都是 `opacity + translate`、同一条曲线、同一时长。
* 换窗格那几处如果将来发现"闪",应该用各自更合适的过渡去解,
* 而不是让**所有**用到 rise 的地方一起背这个不对称。
*/
return TransitionEffect.opacity(0).combine(
TransitionEffect.translate({ y: Theme.riseInOffset })
).animation({ duration: Motion.dur(Theme.durRise), curve: Theme.easeRise });
}
/**
@ -837,11 +874,22 @@ export class Theme {
* 所以 12% 就是“滑入自身宽度的 12%”,不是拍一个 vp 数(那样在大屏上会变小)。
*/
static calendarSlide(forward: boolean): TransitionEffect {
return TransitionEffect.asymmetric(
TransitionEffect.opacity(0).combine(
TransitionEffect.translate({ x: forward ? '12%' : '-12%' })
).animation({ duration: Motion.dur(Theme.durCal), curve: Theme.easeRise }),
TransitionEffect.opacity(0).animation({ duration: Motion.dur(Theme.durFast), curve: Theme.easeRise })
);
/*
* ★★ 2026-09-20 改成**对称**(同 `paneRiseIn`,理由见那处)。
*
* WebUI 这两个类也是 `both`:
* .cal-slide-next { animation: cal-in-next 200ms cubic-bezier(…) both; }
* 即**进出同一条关键帧**。
*
* 我原来写的不对称里,退场只做 `opacity`(120ms)—— 于是翻月时
* 新月份"滑进来"、旧月份只是"淡走",一进一出不是同一件事。
*
* ★ 对称之后,进出的 `x` 是**同一个**(`forward` 已决定方向),
* 所以不会出现"从右边进、从右边出"的别扭感:退出时它往同一侧滑走,
* 与 WebUI 那条 `both` 的行为一致。
*/
return TransitionEffect.opacity(0).combine(
TransitionEffect.translate({ x: forward ? '12%' : '-12%' })
).animation({ duration: Motion.dur(Theme.durCal), curve: Theme.easeRise });
}
}

View File

@ -15,7 +15,15 @@
import { AbilityConstant, ConfigurationConstant, UIAbility, Want } from '@kit.AbilityKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { window } from '@kit.ArkUI';
/*
* ★ `KeyboardAvoidMode` 必须从 `@kit.ArkUI` 引(= `@ohos.arkui.UIContext` 那个)。
*
* 全局作用域里**也有**一个同名枚举(`common.d.ts`,只有 DEFAULT/NONE 两个成员),
* 它不带 `RESIZE` ⇒ 不引这一条就会报:
* Property 'RESIZE' does not exist on type 'typeof KeyboardAvoidMode'.
* 两个同名枚举撞在一起,是本条最费时的一步。
*/
import { KeyboardAvoidMode, window } from '@kit.ArkUI';
import { BusinessError } from '@kit.BasicServicesKit';
import { ApiClient } from '../api/ApiClient';
import { PushService } from '../api/PushService';
@ -127,6 +135,26 @@ export default class EntryAbility extends UIAbility {
return;
}
hilog.info(DOMAIN, 'testTag', 'Succeeded in loading the content.');
/*
* ★ 键盘避让模式改成 `RESIZE`(用户 2026-09-20:「点击回复按键与新建邮件部分
* 的动画与 webui 不一致」→ 连带把回复/转发改成 WebUI 同形的**内联底栏**)。
*
* 为什么这一行是**必须**的,而不是可选项:
* 官方默认是 `KeyboardAvoidMode.OFFSET` —— 键盘弹起时**整页上移**。
* 内联底栏(回复框 / 转发条)是贴在内容**最底下**的一块,
* 页面被推高之后它就出了可视区 ⇒「取消 / 发送 / 转发」点不到。
*
* 旧版是覆盖式弹层(`height('60%')`),弹层自己有固定高度,
* 键盘弹起时内容能在弹层内部重排 —— 所以那时候靠"弹层有高度 + 说明框
* layoutWeight(1)"绕过去了。**结构一改成内联,那条绕法就失效了**,
* 必须正面解决。`harmony-admin` 那条判据当场抓到了这一点。
*
* 选 `RESIZE` 而不是 `NONE`:`NONE` 是"不避让",键盘会直接盖住底栏;
* `RESIZE` 才是"按剩余高度重排",贴底元素留在屏内。
* (`OFFSET_WITH_CARET`/`RESIZE_WITH_CARET` 是 14+ 的"光标移动也触发"变体,
* 我们的底栏只在弹起时关心一次,不需要额外跟随。)
*/
windowStage.getMainWindowSync().getUIContext().setKeyboardAvoidMode(KeyboardAvoidMode.RESIZE);
/*
* ★ 在 loadContent **之后**才配窗口(与示例工程同一位置):
* 示例 `BaseAbilityHelper.doOnWindowStageCreate` 在 loadContent 回调里调

View File

@ -1068,63 +1068,66 @@ export struct MailDetailView {
.width('100%').layoutWeight(1)
// 回复弹层
/*
* ── 回复条(**底部内联**,不是弹层)──
*
* ★★ 2026-09-20 重做(用户:「点击回复按键…动画与 webui 不一致」)。
*
* 改之前是**覆盖式弹层**:整屏遮罩 + `height('60%')` + `position({x:0,y:0})`。
* WebUI `MailView.tsx:1040` 是**底部内联条**:
* className="rise-in shrink-0 max-h-[min(65vh,…)] border-t …"
* 与正文**并列**(正文在上面 `flex-1 overflow-y-auto`),一条 `border-t` 分隔,
* **正文仍可见可滚**,高度由内容决定。
*
* ★ 结构上不用改动外层 —— 原版这里就是"正文 Stack 的兄弟",
* 而它们同在一个 `Column` 里 ⇒ **本来就是竖直排列**。
* 之前的错在于给条加了遮罩/固定高度/绝对定位,把它做成了覆盖层。
* (我一度以为要再加一层 Column,那是把"叠放"和"竖排"搞混了 ——
* `Stack` 才是叠放,而这里的父级是 `Column`。)
*
* 三条用户能感知的差异(这才是"不是样式细节"的地方):
* · 弹层盖住正文 ⇒ 写回复时看不到原文,想引用一句得先关掉;
* · 固定 60% 高 ⇒ 只写一行也占半屏;
* · 遮罩整屏变暗,而 WebUI 写回复时页面亮度不变。
*/
if (this.showReplyBox) {
Column() {
// 遮罩
Column()
.width('100%').layoutWeight(1)
.backgroundColor(Theme.overlay)
.onClick(() => { this.showReplyBox = false; })
Text('回复给 ' + this.replyTargetAddress())
.fontSize(12).fontColor(Theme.textSubtleFor())
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ bottom: 8 })
// 回复框
Column() {
Text('回复给 ' + this.replyTargetAddress())
.fontSize(14).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ bottom: 12 })
TextArea({ placeholder: '输入回复内容…' })
.width('100%').height(96).fontSize(14)
.onChange((v: string) => { this.replyBody = v; })
TextArea({ placeholder: '输入回复内容…' })
.layoutWeight(1).width('100%')
.fontSize(14)
.onChange((v: string) => { this.replyBody = v; })
Row() {
Button('取消')
.width(80).height(36)
.backgroundColor(Theme.surfaceMuted)
.fontColor(Theme.textMuted)
.fontSize(13)
.onClick(() => { this.showReplyBox = false; })
Row() {
Button('取消')
.width(80).height(36)
.backgroundColor(Theme.surfaceMuted)
.fontColor(Theme.textMuted)
.fontSize(13)
.onClick(() => { this.showReplyBox = false; })
Blank()
Blank()
Button(this.sending ? '发送中…' : '发送')
.width(80).height(36)
.backgroundColor(Theme.accent)
.fontSize(13)
.enabled(!this.sending && this.replyBody.length > 0)
.onClick(() => { this.doReply(); })
}
.width('100%')
.margin({ top: 12 })
Button(this.sending ? '发送中…' : '发送')
.width(80).height(36)
.backgroundColor(Theme.accent)
.fontSize(13)
.enabled(!this.sending && this.replyBody.length > 0)
.onClick(() => { this.doReply(); })
}
.width('100%')
.height('60%')
.padding(16)
.backgroundColor(Theme.surface)
.borderRadius({ topLeft: 12, topRight: 12 })
/*
* 回复框入场(对齐 WebUI `MailView.tsx:410` 那条 `rise-in`:上浮 4vp + 淡入)。
*
* ★ 挂在**回复框本身**(下半部那块表面),不是外层遮罩容器:
* WebUI 那处 `rise-in` 也挂在回复框的 div 上,遮罩是立即出现的。
* 挂在遮罩上会让整个屏幕(含变暗的底层内容)一起位移 ——
* 那看起来是"页面在动",而不是"回复框弹出来"。
*/
.transition(Theme.paneRiseIn())
.margin({ top: 8 })
}
.width('100%').height('100%')
.position({ x: 0, y: 0 })
.width('100%')
/* `shrink-0` 的对应物:不给 `layoutWeight` ⇒ 高度由内容决定 */
.padding({ left: 16, right: 16, top: 10, bottom: 10 })
.backgroundColor(Theme.surface)
/* WebUI 是一条 `border-t`,不是四边圆角面板 */
.border({ width: { top: 1 }, color: Theme.border })
.transition(Theme.paneRiseIn())
}
/*
@ -1133,97 +1136,90 @@ export struct MailDetailView {
*
* WebUI 的字段顺序与占位文案逐条对齐(`MailView.tsx:412-437`)。
*/
/*
* ── 转发条(**底部内联**,与回复条同构)──
*
* ★★ 2026-09-20 与回复条一起重做(同一条用户反馈)。WebUI `MailView.tsx:410`
* 也是底部内联条(`rise-in … border-t`),**不是弹层**。
* 两者互斥显示 ——「同时开两个输入框会让人不知道自己在写哪个」
* (WebUI `ForwardBar` 那段注释的原话)。
*
* 字段顺序与占位文案仍逐条对齐 WebUI(`MailView.tsx:412-437`)。
*/
if (this.showForwardBox) {
Column() {
Column()
.width('100%').layoutWeight(1)
.backgroundColor(Theme.overlay)
.onClick(() => { this.showForwardBox = false; })
Column() {
Row() {
Text('转发「' + this.subject + '」')
.fontSize(14).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary)
.layoutWeight(1)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
/* 「抄送」是个**折叠开关**(WebUI 同形:默认收起,点它才出现输入框) */
Text(this.forwardCcOpen ? '收起抄送' : '抄送')
.fontSize(11)
.fontColor(this.forwardCcOpen ? Theme.accentFor() : Theme.textMuted)
.onClick(() => { this.forwardCcOpen = !this.forwardCcOpen; })
}
.width('100%')
.margin({ bottom: 10 })
TextInput({ text: this.forwardTo, placeholder: '新收件人:pi@root.new' })
.width('100%').height(38).fontSize(13)
.onChange((v: string) => { this.forwardTo = v; })
if (this.forwardCcOpen) {
TextInput({ text: this.forwardCc, placeholder: '抄送:逗号分隔,可多个' })
.width('100%').height(38).fontSize(13)
.margin({ top: 8 })
.onChange((v: string) => { this.forwardCc = v; })
}
/*
* 说明框用 `layoutWeight(1)` 而不是固定高度:弹层现在有明确高度
* (`height('60%')`),剩下多少空间它就占多少 —— 这样键盘顶上来时
* 它**自己会缩短**,把「取消 / 转发」按钮留在屏幕内。
* 固定 `height(70)` 在键盘弹出后会把按钮挤出视口(第一版就是这个症状)。
*/
TextArea({
placeholder: '转发说明(可选,置于引用原文之前;原文将以引用块附在下方)'
})
.width('100%').layoutWeight(1).fontSize(13)
.margin({ top: 8 })
.onChange((v: string) => { this.forwardComment = v; })
Row() {
Button('取消')
.width(80).height(36)
.backgroundColor(Theme.surfaceMuted)
.fontColor(Theme.textMuted)
.fontSize(13)
.onClick(() => { this.showForwardBox = false; })
Blank()
Button(this.sending ? '转发中…' : '转发')
.width(80).height(36)
.backgroundColor(Theme.accent)
.fontSize(13)
/* 收件人为空时不可点 —— WebUI 同口径(`disabled={busy || !to.trim()}`) */
.enabled(!this.sending && this.forwardTo.length > 0)
.onClick(() => { this.doForward(); })
}
.width('100%')
.margin({ top: 12 })
Row() {
Text('转发「' + this.subject + '」')
.fontSize(12).fontColor(Theme.textSubtleFor())
.layoutWeight(1)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
/* 「抄送」是个**折叠开关**(WebUI 同形:默认收起,点它才出现输入框) */
Text(this.forwardCcOpen ? '收起抄送' : '抄送')
.fontSize(11)
.fontColor(this.forwardCcOpen ? Theme.accentFor() : Theme.textMuted)
.onClick(() => { this.forwardCcOpen = !this.forwardCcOpen; })
}
.width('100%')
.margin({ bottom: 8 })
TextInput({ text: this.forwardTo, placeholder: '新收件人:pi@root.new' })
.width('100%').height(38).fontSize(13)
.onChange((v: string) => { this.forwardTo = v; })
if (this.forwardCcOpen) {
TextInput({ text: this.forwardCc, placeholder: '抄送:逗号分隔,可多个' })
.width('100%').height(38).fontSize(13)
.margin({ top: 8 })
.onChange((v: string) => { this.forwardCc = v; })
}
/*
* ★★ 2026-09-19 修(真 bug,设备实测撞出来的):
* 第一版这里**没有高度**,只有 `padding(16)` —— 于是弹层的高度
* 完全由内容决定,而内容一旦被键盘顶起来,`TextArea` 与
* 「取消 / 转发」两个按钮就**跑到键盘下面**去了(实测截图:
* 只看得见收件人输入框 + 键盘,「转发」按钮点不到)。
* ★★ 2026-09-20 改:`layoutWeight(1)` → **固定 `height(88)`**。
*
* ArkUI 默认的键盘避让是 `KeyboardAvoidMode.OFFSET`
* (整个布局上移),而要让"上移之后按钮仍在屏幕内",
* 弹层必须有**明确的高度**(内容才能在里面重新分配空间)。
* 原来的 `layoutWeight(1)` 是给"占 60% 屏的弹层"用的 —— 那时要靠它
* 吃掉剩余空间,才不至于让按钮被键盘挤出视口。现在是**底部内联条**
* (高度由内容决定),`layoutWeight` 在一个"不占剩余空间"的容器里
* 没有意义,反而会把它拉成 0 或无限。
*
* 与回复框对齐:那里写的是 `height('60%')`,一直没出问题 ——
* 两个弹层本来就应该同构(同一套交互)。
* 固定 88vp ≈ 4 行说明文字。整条高度因此确定
* (标题 + 收件人 + 可选抄送 + 说明 88 + 按钮行),
* 键盘顶上来时整条上移,按钮自然还在屏内。
*/
.height('60%')
.padding(16)
.backgroundColor(Theme.surface)
.borderRadius({ topLeft: 12, topRight: 12 })
/* 同回复框:`rise-in` 挂在**弹层本身**(理由见回复框那段注释) */
.transition(Theme.paneRiseIn())
TextArea({
placeholder: '转发说明(可选,置于引用原文之前;原文将以引用块附在下方)'
})
.width('100%').height(88).fontSize(13)
.margin({ top: 8 })
.onChange((v: string) => { this.forwardComment = v; })
Row() {
Button('取消')
.width(80).height(36)
.backgroundColor(Theme.surfaceMuted)
.fontColor(Theme.textMuted)
.fontSize(13)
.onClick(() => { this.showForwardBox = false; })
Blank()
Button(this.sending ? '转发中…' : '转发')
.width(80).height(36)
.backgroundColor(Theme.accent)
.fontSize(13)
/* 收件人为空时不可点 —— WebUI 同口径(`disabled={busy || !to.trim()}`) */
.enabled(!this.sending && this.forwardTo.length > 0)
.onClick(() => { this.doForward(); })
}
.width('100%')
.margin({ top: 8 })
}
.width('100%').height('100%')
.position({ x: 0, y: 0 })
.width('100%')
/* `shrink-0` 的对应物:不给 `layoutWeight` ⇒ 高度由内容决定 */
.padding({ left: 16, right: 16, top: 10, bottom: 10 })
.backgroundColor(Theme.surface)
/* 同回复条:一条 `border-t` 分隔 */
.border({ width: { top: 1 }, color: Theme.border })
.transition(Theme.paneRiseIn())
}
}
}