Files
Kanban/src/style.css
T
chenshoulin 9ee1261b90
CI / verify (push) Successful in 1m42s
CI / 部署到服务器 (push) Successful in 29s
feat(ui): 重做深色悬停与键盘焦点,补齐弹层出场动效
悬停:上一版靠「底色跳两档 + 四边边框一起提亮 + 外圈白光晕 + 白环」制造悬浮感,
五个变化同向叠加,结果是整张卡片均匀发亮、看着像换了张更浅的卡,光晕还把边缘糊住。
改用明暗方向表达厚度:新增受光层伪元素(顶部白 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)。
2026-10-24 00:11:51 +08:00

277 lines
9.6 KiB
CSS
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@import 'tailwindcss';
/*
* 深色模式用 class 控制而不是纯 media query ——
* 这样才能支持「跟随系统 / 强制浅色 / 强制深色」三态。
*/
@custom-variant dark (&:where(.dark, .dark *));
@theme {
--font-sans:
system-ui, 'Segoe UI', 'Microsoft YaHei', 'PingFang SC', Roboto, sans-serif;
/* 卡片与面板的阴影:用极低不透明度的多层阴影代替单层重阴影,看起来更干净 */
--shadow-card: 0 1px 2px rgb(15 23 42 / 0.04), 0 1px 3px rgb(15 23 42 / 0.06);
--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 {
html,
body,
#app {
height: 100%;
}
html {
color-scheme: light;
}
html.dark {
color-scheme: dark;
}
body {
margin: 0;
background: var(--color-slate-100);
color: var(--color-slate-900);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
/*
* 深色用 zinc(中性灰)而不是 slate:slate 带明显蓝调,
* 整页会发蓝,和「中性灰」的目标色差得很远。
*/
html.dark body {
background: var(--color-zinc-950);
color: var(--color-zinc-100);
}
/*
* 统一的键盘焦点样式,鼠标点击不显示。
* 不写 border-radius:outline 会自动跟随元素自身的圆角,早先那行
* `border-radius: 4px` 反而会让没有圆角类的元素在聚焦瞬间跳一下。
*/
:focus-visible {
outline: 2px solid var(--color-sky-500);
outline-offset: 2px;
}
/* 深色底上纯 sky-500 有点扎眼,掺一点透明度把它压柔 */
html.dark :focus-visible {
outline-color: color-mix(in oklab, var(--color-sky-400) 78%, transparent);
}
::selection {
background: var(--color-sky-200);
}
html.dark ::selection {
background: var(--color-sky-800);
}
/* 细滚动条,避免在列里出现粗重的系统滚动条 */
* {
scrollbar-width: thin;
scrollbar-color: var(--color-slate-300) transparent;
}
html.dark * {
scrollbar-color: var(--color-zinc-700) transparent;
}
}
/*
* 注:日期输入框空值时的原生提示(「yyyy / mm / 日」)不在这里处理。
* 试过把 `::-webkit-datetime-edit` 系列伪元素设成透明,但部分环境下不生效,
* 已改为在 DatePicker.vue 里用不透明占位层直接盖住。
*/
/*
* 深色模式下的卡片高度(悬浮)
*
* 浅色靠「黑色投影落在浅灰上」表达高度;深色里同一个投影落在 zinc-950 上
* 没有对比度。只把参数调大也不行 —— 之前那版同时做了「底色跳两档 + 四边边框
* 一起提亮 + 外圈白光晕」,五个变化一起发生,结果是整张卡片均匀发亮,
* 看着像换了张更浅的卡,而不是同一张卡被抬起来,光晕还让边缘发糊。
*
* 现在只保留能表达「厚度」的三件事,并且让它们方向一致 —— 上亮、下暗:
* 1. 受光渐变(见下面的 ::before):顶部白、底部黑,眼睛据此读出顶面朝上;
* 2. 锐利的接触阴影:卡片边缘与列背景之间一道很窄的暗隙,先把边界压实;
* 3. 远处柔化投影:透明度低、扩散大,只提供高度暗示,不参与轮廓。
* 底色只提一档、边框只提一档、位移仍在 2px —— 让「抬起」由明暗关系表达,
* 而不是靠整体变亮。
*
* 注意两点:
* 1. Tailwind v4 会把 @theme 里的阴影值内联进 --tw-shadow,改 CSS 变量对
* 已经生成的工具类无效,所以这里直接重写 box-shadow;
* 2. 悬停阴影在组件里写的是 `hover:shadow-card-hover`,生成的类名带转义前缀
* (.hover\:shadow-card-hover),必须按这个选择器写,否则静默不生效。
*
* 这些规则不在任何 @layer 里,优先级高于 utilities 层,能稳定覆盖原子类。
*/
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.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 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。
* 位置必须在 :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 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:
-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);
}