feat(ui): 重做深色悬停与键盘焦点,补齐弹层出场动效
CI / verify (push) Successful in 1m42s
CI / 部署到服务器 (push) Successful in 29s

悬停:上一版靠「底色跳两档 + 四边边框一起提亮 + 外圈白光晕 + 白环」制造悬浮感,
五个变化同向叠加,结果是整张卡片均匀发亮、看着像换了张更浅的卡,光晕还把边缘糊住。
改用明暗方向表达厚度:新增受光层伪元素(顶部白 7% → 底部黑 14%,悬停淡入),
配一道锐利的接触阴影压实边界、一层远处柔化投影作高度暗示;底色与边框各只提一档。
过渡 150ms → 200ms ease-out。新增按下态:位移归零、阴影收紧、受光减弱,卡片「落回纸面」。

焦点:全局描边在深色下掺透明度柔化(sky-400 78%);卡片被 :focus-visible 时与悬停
一致地抬起并受光,焦点环退为最后一道提示,不再是一圈生硬方框。顺带删掉
:focus-visible 里的 border-radius: 4px —— 对带 rounded-* 的元素无效(layer 优先级),
对没圆角的元素反而会让聚焦瞬间跳一下。

动效:新增 pop-in(160ms) / sheet-in(260ms) / scrim-in(200ms) 三组关键帧,
下拉从锚点上方落入、抽屉自右侧滑入、遮罩淡入,组件上一律配 motion-reduce:animate-none。
只做入场不做退场:关闭是用户主动取消,瞬时消失更利落;做退场要引入 Vue Transition,
会牵扯 SideDrawer 的焦点与卸载时序,收益不抵风险。

实现要点:
- Tailwind v4 会把 @theme 的阴影值内联进 --tw-shadow,覆盖 CSS 变量对已生成的工具类
  无效,深色阴影因此直接重写 box-shadow,且规则不放进 @layer 以压过原子类;
  选择器要按 hover: 变体转义后的类名书写(.hover\:shadow-card-hover),否则静默失效
- 焦点态位移不能只改 --tw-translate-y:translate 属性声明只存在于 :hover 那条原子类
  规则里,焦点态不经过 :hover,必须直接写 translate
- 受光层要画在内容之下,因此 TaskCard 的内容容器补了 relative(同为定位元素时按 DOM
  顺序绘制,伪元素在前)

验证:vue-tsc -b 与 eslint src 干净,738 例全量单测通过;Playwright 读计算样式确认
动画时序(下拉 @30ms opacity .909、抽屉 translateX 214.77px、遮罩 opacity .419),
并用垂直亮度剖面确认卡片上亮下暗(内部亮度 33 → 20)。
This commit is contained in:
chenshoulin
2026-10-24 00:11:51 +08:00
parent 69a868a7b1
commit 9ee1261b90
6 changed files with 156 additions and 28 deletions
+148 -20
View File
@@ -15,6 +15,45 @@
--shadow-card-hover: 0 6px 16px rgb(15 23 42 / 0.09), 0 1px 3px rgb(15 23 42 / 0.05);
--shadow-panel: 0 16px 40px rgb(15 23 42 / 0.12), 0 2px 8px rgb(15 23 42 / 0.06);
--shadow-pop: 0 8px 24px rgb(15 23 42 / 0.12), 0 1px 3px rgb(15 23 42 / 0.06);
/*
* 出场动效。原则是「短、只做一次、不挡路」:
* 下拉从锚点上方几像素落下,抽屉从右侧滑入,遮罩单独淡入。
* 时长压在 160~260ms —— 再长就会觉得界面迟钝。
* 组件上一律配 motion-reduce:animate-none,尊重系统的减少动效设置。
*/
--animate-pop-in: pop-in 160ms cubic-bezier(0.2, 0.7, 0.2, 1);
--animate-sheet-in: sheet-in 260ms cubic-bezier(0.2, 0.7, 0.2, 1);
--animate-scrim-in: scrim-in 200ms ease-out;
@keyframes pop-in {
from {
opacity: 0;
transform: translateY(-5px) scale(0.98);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes sheet-in {
from {
transform: translateX(100%);
}
to {
transform: none;
}
}
@keyframes scrim-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
}
@layer base {
@@ -50,11 +89,19 @@
color: var(--color-zinc-100);
}
/* 统一的键盘焦点样式,鼠标点击不显示 */
/*
* 统一的键盘焦点样式,鼠标点击不显示。
* 不写 border-radius:outline 会自动跟随元素自身的圆角,早先那行
* `border-radius: 4px` 反而会让没有圆角类的元素在聚焦瞬间跳一下。
*/
:focus-visible {
outline: 2px solid var(--color-sky-500);
outline-offset: 2px;
border-radius: 4px;
}
/* 深色底上纯 sky-500 有点扎眼,掺一点透明度把它压柔 */
html.dark :focus-visible {
outline-color: color-mix(in oklab, var(--color-sky-400) 78%, transparent);
}
::selection {
@@ -83,15 +130,19 @@
*/
/*
* 深色模式下的卡片阴影
* 深色模式下的卡片高度(悬浮)
*
* 上面 @theme 里的阴影是「低透明度黑色」,落在近黑的 zinc-950 背景上几乎没有
* 对比度 —— 浅色模式下鼠标悬停能明显看到卡片被抬起来,深色模式下就基本没反应。
* 浅色靠「黑色投影落在浅灰上」表达高度;深色里同一个投影落在 zinc-950 上
* 没有对比度。只把参数调大也不行 —— 之前那版同时做了「底色跳两档 + 四边边框
* 一起提亮 + 外圈白光晕」,五个变化一起发生,结果是整张卡片均匀发亮,
* 看着像换了张更浅的卡,而不是同一张卡被抬起来,光晕还让边缘发糊。
*
* 深色不能照搬同一套参数,改用三件事来还原同样的「悬浮感」:
* 1. 外扩暗晕:把卡片四周压得更暗,卡片本身因此显得更亮更高;
* 2. 顶部内高光:模拟顶面受光,这是深色下唯一能画出「厚度」的手段;
* 3. 悬停时同步提亮卡片底色(见 TaskCard.vue 的 dark:hover:bg-*)。
* 现在只保留能表达「厚度」的三件事,并且让它们方向一致 —— 上亮、下暗:
* 1. 受光渐变(见下面的 ::before):顶部白、底部黑,眼睛据此读出顶面朝上;
* 2. 锐利的接触阴影:卡片边缘与列背景之间一道很窄的暗隙,先把边界压实;
* 3. 远处柔化投影:透明度低、扩散大,只提供高度暗示,不参与轮廓。
* 底色只提一档、边框只提一档、位移仍在 2px —— 让「抬起」由明暗关系表达,
* 而不是靠整体变亮。
*
* 注意两点:
* 1. Tailwind v4 会把 @theme 里的阴影值内联进 --tw-shadow,改 CSS 变量对
@@ -104,45 +155,122 @@
html.dark .shadow-card {
box-shadow:
var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow),
var(--tw-ring-shadow), 0 1px 2px rgb(0 0 0 / 0.55), inset 0 1px 0 rgb(255 255 255 / 0.03);
var(--tw-ring-shadow), 0 1px 2px rgb(0 0 0 / 0.4), inset 0 1px 0 rgb(255 255 255 / 0.035);
}
/*
* 悬停:暗晕压四周 + 一圈极淡的白光晕 + 顶部内高光。
* 白光晕是深色下唯一能真正"画出来"的扩散效果,黑色投影只负责把近处压暗。
* 受光层:顶部白 → 中部透明 → 底部黑。
*
* 用 background-image 做不了过渡,所以挂一层伪元素淡入。它必须画在卡片内容
* 之下,因此 TaskCard.vue 里的内容容器带了 relative(同为定位元素时按 DOM
* 顺序绘制,伪元素在前,内容在后)。
*/
html.dark .hover\:shadow-card-hover::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
background: linear-gradient(
180deg,
rgb(255 255 255 / 0.07) 0%,
rgb(255 255 255 / 0.02) 32%,
rgb(0 0 0 / 0.14) 100%
);
opacity: 0;
transition: opacity 200ms cubic-bezier(0.2, 0.7, 0.2, 1);
}
html.dark .hover\:shadow-card-hover:hover::before {
opacity: 1;
}
html.dark .hover\:shadow-card-hover:hover {
box-shadow:
var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow),
var(--tw-ring-shadow), 0 14px 30px -10px rgb(0 0 0 / 0.9), 0 0 22px -4px rgb(255 255 255 / 0.07),
0 0 0 1px rgb(255 255 255 / 0.08), inset 0 1px 0 rgb(255 255 255 / 0.12);
var(--tw-ring-shadow), 0 1px 1px rgb(0 0 0 / 0.45), 0 2px 4px -1px rgb(0 0 0 / 0.45),
0 14px 30px -16px rgb(0 0 0 / 0.9), inset 0 1px 0 rgb(255 255 255 / 0.09);
}
/* 深色下 1px 的位移在暗底上几乎看不出来,补偿到 2px */
/* 深色下 1px 的位移在暗底上几乎看不出来,补偿到 2px。
* 位置必须在 :active 之前 —— 两者特异性相同,靠顺序决定胜负。 */
html.dark .hover\:-translate-y-px:hover {
--tw-translate-y: -2px;
}
/*
* 按下:落回纸面 —— 位移归零、阴影收紧回静止态、受光减弱。
* 必须写在 :hover 与上面的位移补偿之后:三者特异性相同,靠顺序决定胜负。
*/
html.dark .hover\:shadow-card-hover:active {
--tw-translate-y: 0px;
box-shadow:
var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow),
var(--tw-ring-shadow), 0 1px 2px rgb(0 0 0 / 0.5), inset 0 1px 0 rgb(255 255 255 / 0.05);
}
html.dark .hover\:shadow-card-hover:active::before {
opacity: 0.4;
}
/* 浅色同步补一个「按下」,两侧手感一致(浅色不需要受光层) */
.hover\:shadow-card-hover:active {
--tw-translate-y: 0px;
box-shadow:
var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow),
var(--tw-ring-shadow), 0 1px 2px rgb(15 23 42 / 0.06), 0 1px 3px rgb(15 23 42 / 0.08);
}
/*
* 键盘焦点:卡片被聚焦时和悬停一样抬起、受光,焦点环只是最后一道提示。
* 只有一圈描边的话,观感是「外面套了个方框」,和这套用明暗表达高度的语言不搭。
*
* 这里直接写 translate 属性而不是改 --tw-translate-y:后者只有在 :hover 规则
* 声明过 `translate: var(...)` 时才起作用,焦点状态并不经过 :hover。
*/
.hover\:shadow-card-hover:focus-visible {
translate: 0 -1px;
box-shadow:
var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow),
var(--tw-ring-shadow), 0 6px 16px rgb(15 23 42 / 0.09), 0 1px 3px rgb(15 23 42 / 0.05);
}
html.dark .hover\:shadow-card-hover:focus-visible {
translate: 0 -2px;
box-shadow:
var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow),
var(--tw-ring-shadow), 0 1px 1px rgb(0 0 0 / 0.45), 0 2px 4px -1px rgb(0 0 0 / 0.45),
0 14px 30px -16px rgb(0 0 0 / 0.9), inset 0 1px 0 rgb(255 255 255 / 0.09);
}
html.dark .hover\:shadow-card-hover:focus-visible::before {
opacity: 1;
}
/*
* 深色模式下的弹层阴影(下拉 / 抽屉)
*
* 和卡片同样的道理:shadow-pop / shadow-panel 在深色下是「黑色投在黑底上」。
* 但这里不能用 `html.dark .shadow-pop` 统一覆盖 —— ToastHost / PwaUpdatePrompt
* 在深色下仍然是浅色底(dark:bg-zinc-100),它们需要保留原来的黑色投影,
* 否则亮块上套一圈白光晕会很脏。
* 给亮块套深色阴影会把它们压得发灰。
*
* 所以改成显式标记:深色底面的弹层多挂一个 shadow-pop-dark / shadow-panel-dark,
* 只有挂了标记的元素才会被下面的规则接管。类名带 html.dark 前缀 + 未分层,
* 特异性 (0,2,1) 高于原子类的 (0,1,0),不受 CSS 生成顺序影响。
*
* 和卡片用同一套语言:接触阴影压实边界,远处柔化投影提供高度,不加白色光晕
* —— 光晕在深色里会雾化边缘,和卡片那版踩过同一个坑。
*/
html.dark .shadow-pop-dark {
box-shadow:
0 12px 28px -8px rgb(0 0 0 / 0.85), 0 2px 8px -2px rgb(0 0 0 / 0.6),
0 0 22px -6px rgb(255 255 255 / 0.06);
0 1px 1px rgb(0 0 0 / 0.45), 0 4px 10px -3px rgb(0 0 0 / 0.5),
0 16px 34px -16px rgb(0 0 0 / 0.9);
}
/* 抽屉从右侧滑入,主投影朝左;其余方向补一圈淡光晕把边缘从暗背景里勾出来 */
/* 抽屉从右侧滑入,主投影朝左,三层由近到远 */
html.dark .shadow-panel-dark {
box-shadow:
-16px 0 48px -12px rgb(0 0 0 / 0.9), 0 0 30px -10px rgb(255 255 255 / 0.05);
-1px 0 1px rgb(0 0 0 / 0.4), -10px 0 30px -12px rgb(0 0 0 / 0.7),
-34px 0 70px -30px rgb(0 0 0 / 0.85);
}