悬停:上一版靠「底色跳两档 + 四边边框一起提亮 + 外圈白光晕 + 白环」制造悬浮感, 五个变化同向叠加,结果是整张卡片均匀发亮、看着像换了张更浅的卡,光晕还把边缘糊住。 改用明暗方向表达厚度:新增受光层伪元素(顶部白 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)。
277 lines
9.6 KiB
CSS
277 lines
9.6 KiB
CSS
@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);
|
||
}
|