@deernova/style 0.3.3 → 0.3.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/skill/SKILL.md +9 -7
- package/skill/assets/DeernovaTokens.swift +3 -3
- package/skill/assets/tokens.css +5 -0
- package/skill/assets/tokens.json +40 -6
- package/skill/manifest.json +2 -2
- package/skill/references/components/banner.md +8 -1
- package/skill/references/components/button.md +6 -4
- package/skill/references/components/card.md +1 -1
- package/skill/references/components/composer.md +2 -1
- package/skill/references/components/menu.md +9 -5
- package/skill/references/components/segmented.md +1 -1
- package/skill/references/components/sheet.md +63 -12
- package/skill/references/components/sidebar.md +7 -2
- package/skill/references/components/step-row.md +8 -4
- package/skill/references/components/toolbar.md +5 -4
- package/skill/references/components/tooltip.md +3 -2
- package/skill/references/foundations/color.md +1 -1
- package/skill/references/foundations/layout.md +60 -1
- package/skill/references/foundations/motion.md +111 -9
- package/skill/references/foundations/states.md +98 -0
- package/skill/references/review.md +2 -2
- package/skill/references/scenes/web.md +3 -3
- package/skill/references/tokens.md +11 -6
package/package.json
CHANGED
package/skill/SKILL.md
CHANGED
|
@@ -53,11 +53,12 @@ description: Deernova 的界面设计语言:只用黑白灰,内容放在不
|
|
|
53
53
|
2. **黑白灰,强调靠对比。** `accent` 就是墨色 `ink`:主按钮在浅色主题是黑底白字,在深色主题是白底黑字,文字用 `on-accent`,不要写死白色。唯一的彩色是状态色 `success`、`warning`、`danger`、`info`,只用来表达状态,而且必须同时带图标或文字。
|
|
54
54
|
3. **线条代替阴影。** 区块之间用 `line` 色的 1px 线分隔(`stroke-line`)。卡片不加阴影;只有菜单、弹窗、浮动面板和 Composer 这类真正浮起来的东西才用 `shadow-float`。
|
|
55
55
|
4. **无衬线做界面,衬线点题。** 界面文字、AI 回答、用户消息、标题栏都用系统无衬线(SF Pro);代码和需要对齐的数字用等宽(SF Mono)。衬线(New York)只用在页面大标题和空状态标题(`display`、`title`)、官网与文档,以及文章类的长文阅读(`prose`)。中文统一落到苹方。
|
|
56
|
-
5.
|
|
57
|
-
6.
|
|
58
|
-
7.
|
|
59
|
-
|
|
60
|
-
|
|
56
|
+
5. **状态只有两种底色。** 悬停 `fill-hover`,按下和选中 `fill-selected`,都是半透明的,叠在任何纸面上都成立;选中项悬停时不再加深。悬停只改颜色,不加阴影、不位移,只写在 `@media (hover: hover)` 里,见 [交互状态](references/foundations/states.md)。
|
|
57
|
+
6. **层次靠字号和灰度,少用粗体。** 600 只给真正的标题(`display`、`title`、`headline`)。分组标题、会话名、说明、时间戳用常规字重,靠 `ink-secondary`、`ink-tertiary` 和小一号的字区分层级。
|
|
58
|
+
7. **控件高度由 token 决定,点击区域单独补。** 按钮、输入框、列表行的可见高度直接用 `button-height-*`、`field-height`、`row-height`,上下内边距为 0,不让页面行高把它撑高。可见尺寸小于 `hit-min` 时,用透明的命中区补足,不加大可见的控件。
|
|
59
|
+
8. **间距成组。** 间距走 4pt 网格。组内用 `gap-stack`,组与组之间用 `gap-group`(至少 2 倍);内容区里段落、工具调用、卡片之间 `space-4`。用细线和少量留白分组,不靠大块空白和外框。
|
|
60
|
+
9. **原生端用系统控件,Web 端用同一套自绘组件。** SwiftUI 端的导航结构、侧边栏、工具栏、菜单、表单、弹窗直接用系统控件和系统尺寸,只通过颜色和字体让它们融入;自己画的内容区(对话、卡片、输入区、任务清单)用 token。Web 应用没有系统控件可借,按组件说明里的规格画,骨架见 [Web 应用](references/scenes/web.md)。
|
|
61
|
+
10. **对齐按内容选。** 单行的控件和行在固定高度里垂直居中;图标和文字按中线,不同字号的文字按基线;三行以上的内容,图标和头像对齐第一行;空状态放在略高于正中的位置。不用内边距或 `top` 微调去凑居中。内容栏限宽并在主内容区里居中,见 [布局与对齐](references/foundations/layout.md)。
|
|
61
62
|
|
|
62
63
|
## 读什么
|
|
63
64
|
|
|
@@ -73,11 +74,11 @@ description: Deernova 的界面设计语言:只用黑白灰,内容放在不
|
|
|
73
74
|
|
|
74
75
|
### 基础
|
|
75
76
|
|
|
76
|
-
[颜色](references/foundations/color.md) · [字体](references/foundations/typography.md) · [间距](references/foundations/spacing.md) · [布局与对齐](references/foundations/layout.md) · [圆角](references/foundations/radius.md) · [线条](references/foundations/stroke.md) · [层级与阴影](references/foundations/elevation.md) · [玻璃](references/foundations/glass.md) · [动效](references/foundations/motion.md) · [图标](references/foundations/iconography.md) · [文案](references/foundations/writing.md) · [无障碍](references/foundations/accessibility.md)
|
|
77
|
+
[颜色](references/foundations/color.md) · [交互状态](references/foundations/states.md) · [字体](references/foundations/typography.md) · [间距](references/foundations/spacing.md) · [布局与对齐](references/foundations/layout.md) · [圆角](references/foundations/radius.md) · [线条](references/foundations/stroke.md) · [层级与阴影](references/foundations/elevation.md) · [玻璃](references/foundations/glass.md) · [动效](references/foundations/motion.md) · [图标](references/foundations/iconography.md) · [文案](references/foundations/writing.md) · [无障碍](references/foundations/accessibility.md)
|
|
77
78
|
|
|
78
79
|
### 组件
|
|
79
80
|
|
|
80
|
-
[按钮](references/components/button.md) · [顶栏与侧面板](references/components/toolbar.md) · [玻璃操作层](references/components/glass-controls.md) · [分段控件](references/components/segmented.md) · [文本框](references/components/text-field.md) · [菜单与下拉](references/components/menu.md) · [Composer](references/components/composer.md) · [侧边栏](references/components/sidebar.md) · [分组列表](references/components/list.md) · [卡片](references/components/card.md) · [标签](references/components/tag.md) · [代码块](references/components/code-block.md) · [任务清单](references/components/task-list.md) · [空状态](references/components/empty-state.md) · [横幅与轻提示](references/components/banner.md) · [
|
|
81
|
+
[按钮](references/components/button.md) · [顶栏与侧面板](references/components/toolbar.md) · [玻璃操作层](references/components/glass-controls.md) · [分段控件](references/components/segmented.md) · [文本框](references/components/text-field.md) · [菜单与下拉](references/components/menu.md) · [Composer](references/components/composer.md) · [侧边栏](references/components/sidebar.md) · [分组列表](references/components/list.md) · [卡片](references/components/card.md) · [标签](references/components/tag.md) · [代码块](references/components/code-block.md) · [任务清单](references/components/task-list.md) · [空状态](references/components/empty-state.md) · [横幅与轻提示](references/components/banner.md) · [弹窗、抽屉与 popover](references/components/sheet.md) · [Tooltip 与悬停浮层](references/components/tooltip.md) · [消息](references/components/message.md) · [工具调用与执行步骤](references/components/step-row.md)
|
|
81
82
|
|
|
82
83
|
### 场景
|
|
83
84
|
|
|
@@ -97,6 +98,7 @@ description: Deernova 的界面设计语言:只用黑白灰,内容放在不
|
|
|
97
98
|
- 在 SwiftUI 端给系统工具栏、侧边栏加实色背景盖掉玻璃;在 Web 应用里用模糊效果模仿玻璃。
|
|
98
99
|
- 给卡片加阴影;在玻璃上放正文或列表。
|
|
99
100
|
- 给界面文字、AI 回答或按钮用衬线;到处用粗体。
|
|
101
|
+
- 浮层只有出现动画、关闭时一下子消失;用 `@keyframes` 做开合。
|
|
100
102
|
- 用上下内边距撑出按钮高度;为了凑 44 点击区域把可见的按钮做大;用 `padding-top`、`top: 1px` 或等高的 `line-height` 凑垂直居中。
|
|
101
103
|
- 把官网的大按钮、大字号搬进应用界面;把桌面端的尺寸直接搬到触屏上。
|
|
102
104
|
- 一屏出现两个主按钮;用「确定」「OK」当按钮文字。
|
|
@@ -156,11 +156,11 @@ public enum Deernova {
|
|
|
156
156
|
public static let strokeFocus: CGFloat = 2
|
|
157
157
|
/// 焦点环和控件边缘之间的距离。
|
|
158
158
|
public static let focusOffset: CGFloat = 2
|
|
159
|
-
///
|
|
159
|
+
/// 悬停、按压、选中这类即时反馈;tooltip 的进出;菜单、popover 的退出。
|
|
160
160
|
public static let durationInstant: Double = 0.12
|
|
161
|
-
///
|
|
161
|
+
/// 菜单、popover、轻提示的进入;展开收起;弹窗和抽屉的退出。
|
|
162
162
|
public static let durationBase: Double = 0.2
|
|
163
|
-
///
|
|
163
|
+
/// 弹窗、底部弹窗、侧边抽屉的进入,页面级转场。
|
|
164
164
|
public static let durationSlow: Double = 0.32
|
|
165
165
|
/// 禁用的控件。禁用时保持原位,不隐藏。
|
|
166
166
|
public static let opacityDisabled: Double = 0.35
|
package/skill/assets/tokens.css
CHANGED
|
@@ -153,6 +153,9 @@
|
|
|
153
153
|
--duration-base: 200ms;
|
|
154
154
|
--duration-slow: 320ms;
|
|
155
155
|
--ease-standard: cubic-bezier(0.2, 0.8, 0.2, 1);
|
|
156
|
+
--ease-enter: cubic-bezier(0.16, 1, 0.3, 1);
|
|
157
|
+
--ease-exit: cubic-bezier(0.4, 0, 0.2, 1);
|
|
158
|
+
--ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);
|
|
156
159
|
--opacity-disabled: 0.35;
|
|
157
160
|
--opacity-solid-hover: 0.88;
|
|
158
161
|
--glass-blur: 16px;
|
|
@@ -160,6 +163,8 @@
|
|
|
160
163
|
--content-max: 680px;
|
|
161
164
|
--form-max: 560px;
|
|
162
165
|
--wide-max: 1120px;
|
|
166
|
+
--scrollbar-size: 10px;
|
|
167
|
+
--scrollbar-thumb-size: 6px;
|
|
163
168
|
--menu-max-width: 320px;
|
|
164
169
|
--sheet-grabber-width: 36px;
|
|
165
170
|
--sheet-grabber-height: 5px;
|
package/skill/assets/tokens.json
CHANGED
|
@@ -948,28 +948,46 @@
|
|
|
948
948
|
]
|
|
949
949
|
},
|
|
950
950
|
"motion": {
|
|
951
|
-
"note": "
|
|
951
|
+
"note": "动效时长和缓动。进入用 ease-enter,退出用 ease-exit 并降一档时长,从屏幕边缘滑入的面板用 ease-drawer。SwiftUI 里默认用 .smooth(等同 .spring(response: 0.35, dampingFraction: 0.85) 的手感)。",
|
|
952
952
|
"tokens": [
|
|
953
953
|
{
|
|
954
954
|
"name": "duration-instant",
|
|
955
955
|
"value": "120ms",
|
|
956
|
-
"usage": "
|
|
956
|
+
"usage": "悬停、按压、选中这类即时反馈;tooltip 的进出;菜单、popover 的退出。"
|
|
957
957
|
},
|
|
958
958
|
{
|
|
959
959
|
"name": "duration-base",
|
|
960
960
|
"value": "200ms",
|
|
961
|
-
"usage": "
|
|
961
|
+
"usage": "菜单、popover、轻提示的进入;展开收起;弹窗和抽屉的退出。"
|
|
962
962
|
},
|
|
963
963
|
{
|
|
964
964
|
"name": "duration-slow",
|
|
965
965
|
"value": "320ms",
|
|
966
|
-
"usage": "
|
|
966
|
+
"usage": "弹窗、底部弹窗、侧边抽屉的进入,页面级转场。"
|
|
967
967
|
},
|
|
968
968
|
{
|
|
969
969
|
"name": "ease-standard",
|
|
970
970
|
"value": "cubic-bezier(0.2, 0.8, 0.2, 1)",
|
|
971
971
|
"swift": false,
|
|
972
|
-
"usage": "
|
|
972
|
+
"usage": "状态变化和展开收起:颜色、高度、箭头旋转。快进、缓停。"
|
|
973
|
+
},
|
|
974
|
+
{
|
|
975
|
+
"name": "ease-enter",
|
|
976
|
+
"value": "cubic-bezier(0.16, 1, 0.3, 1)",
|
|
977
|
+
"swift": false,
|
|
978
|
+
"usage": "出现:菜单、popover、弹窗、轻提示。起步快、停得很柔,前一半时间就完成大部分位移。"
|
|
979
|
+
},
|
|
980
|
+
{
|
|
981
|
+
"name": "ease-exit",
|
|
982
|
+
"value": "cubic-bezier(0.4, 0, 0.2, 1)",
|
|
983
|
+
"swift": false,
|
|
984
|
+
"usage": "消失:配比进入低一档的时长,不拖泥带水。"
|
|
985
|
+
},
|
|
986
|
+
{
|
|
987
|
+
"name": "ease-drawer",
|
|
988
|
+
"value": "cubic-bezier(0.32, 0.72, 0, 1)",
|
|
989
|
+
"swift": false,
|
|
990
|
+
"usage": "从屏幕边缘滑入滑出的面板:侧边抽屉、底部弹窗,进出都用它。"
|
|
973
991
|
}
|
|
974
992
|
]
|
|
975
993
|
},
|
|
@@ -1107,6 +1125,22 @@
|
|
|
1107
1125
|
"comfortable": 32
|
|
1108
1126
|
},
|
|
1109
1127
|
"usage": "官网和文档 12 栏网格的栏间距。应用里的卡片网格用 card-gap。"
|
|
1128
|
+
},
|
|
1129
|
+
{
|
|
1130
|
+
"name": "scrollbar-size",
|
|
1131
|
+
"value": "10px",
|
|
1132
|
+
"platforms": [
|
|
1133
|
+
"web"
|
|
1134
|
+
],
|
|
1135
|
+
"usage": "网页滚动条占的宽度(横向滚动条是高度)。"
|
|
1136
|
+
},
|
|
1137
|
+
{
|
|
1138
|
+
"name": "scrollbar-thumb-size",
|
|
1139
|
+
"value": "6px",
|
|
1140
|
+
"platforms": [
|
|
1141
|
+
"web"
|
|
1142
|
+
],
|
|
1143
|
+
"usage": "滚动条里胶囊滑块的粗细,两边各留 (scrollbar-size − 它) ÷ 2。"
|
|
1110
1144
|
}
|
|
1111
1145
|
]
|
|
1112
1146
|
},
|
|
@@ -1719,7 +1753,7 @@
|
|
|
1719
1753
|
},
|
|
1720
1754
|
{
|
|
1721
1755
|
"id": "sheet",
|
|
1722
|
-
"title": "
|
|
1756
|
+
"title": "弹窗、抽屉与 popover",
|
|
1723
1757
|
"reference": "references/components/sheet.md",
|
|
1724
1758
|
"tokens": [
|
|
1725
1759
|
{
|
package/skill/manifest.json
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
- 圆角 `banner-radius`,内边距上下 `space-2`、左右 `space-3`,图标 `icon-size-md`;标题 `type-body` 半粗,正文 `callout`。状态图标见 [图标](../foundations/iconography.md)。
|
|
18
18
|
- 动作按钮用小号次按钮,放在右侧,一个横幅最多一个。
|
|
19
19
|
- 和输入区有关的提示(额度将用完、网络断开)贴在 Composer 正上方,宽度和 Composer 一致,见 [Composer](composer.md)。
|
|
20
|
-
- 轻提示:底部居中的玻璃胶囊,高 `toast-height`,2
|
|
20
|
+
- 轻提示:底部居中的玻璃胶囊,高 `toast-height`,2 秒后自动消失(出现时淡入并上移 8px,消失时淡出,见 [动效](../foundations/motion.md)),只用来确认刚刚完成的操作(「已复制」「已保存」),文字用完成态,不放按钮。读屏用 `role="status"`。
|
|
21
21
|
|
|
22
22
|
## Web
|
|
23
23
|
|
|
@@ -44,6 +44,13 @@
|
|
|
44
44
|
.dn-banner.is-danger > .dn-icon { color: var(--danger); }
|
|
45
45
|
|
|
46
46
|
.dn-toast { display: inline-flex; align-items: center; gap: var(--space-2); height: var(--toast-height); padding: 0 var(--space-4) 0 var(--space-3); color: var(--ink); font: var(--weight-medium) var(--type-body-size)/1 var(--font-sans); }
|
|
47
|
+
.dn-toast { transition: opacity var(--duration-base) var(--ease-enter), transform var(--duration-base) var(--ease-enter), display var(--duration-base) allow-discrete; }
|
|
48
|
+
.dn-toast[hidden] { display: none; opacity: 0; transform: translateY(8px); transition-duration: var(--duration-instant); transition-timing-function: var(--ease-exit); }
|
|
49
|
+
@starting-style { .dn-toast:not([hidden]) { opacity: 0; transform: translateY(8px); } }
|
|
50
|
+
@media (prefers-reduced-motion: reduce) {
|
|
51
|
+
.dn-toast[hidden] { transform: none; }
|
|
52
|
+
@starting-style { .dn-toast:not([hidden]) { transform: none; } }
|
|
53
|
+
}
|
|
47
54
|
.dn-toast .dn-icon { width: var(--icon-size-md); height: var(--icon-size-md); color: var(--success); }
|
|
48
55
|
```
|
|
49
56
|
|
|
@@ -72,15 +72,16 @@
|
|
|
72
72
|
cursor: pointer;
|
|
73
73
|
transition: background-color var(--duration-instant) var(--ease-standard), opacity var(--duration-instant) var(--ease-standard);
|
|
74
74
|
}
|
|
75
|
-
.dn-button:hover:not(:disabled) { background-image: linear-gradient(var(--fill-hover), var(--fill-hover)); }
|
|
75
|
+
@media (hover: hover) { .dn-button:hover:not(:disabled) { background-image: linear-gradient(var(--fill-hover), var(--fill-hover)); } }
|
|
76
76
|
.dn-button:active:not(:disabled) { background-image: linear-gradient(var(--fill-selected), var(--fill-selected)); }
|
|
77
77
|
.dn-button:focus-visible { outline: var(--stroke-focus) solid var(--focus-ring); outline-offset: var(--focus-offset); }
|
|
78
78
|
.dn-button:disabled { opacity: var(--opacity-disabled); cursor: default; }
|
|
79
79
|
|
|
80
80
|
.dn-button.is-primary { background: var(--accent); color: var(--on-accent); }
|
|
81
|
-
.dn-button.is-primary:hover:not(:disabled)
|
|
81
|
+
@media (hover: hover) { .dn-button.is-primary:hover:not(:disabled) { background-image: none; opacity: var(--opacity-solid-hover); } }
|
|
82
|
+
.dn-button.is-primary:active:not(:disabled) { background-image: none; opacity: var(--opacity-solid-hover); }
|
|
82
83
|
.dn-button.is-plain { background: transparent; color: var(--ink-secondary); }
|
|
83
|
-
.dn-button.is-plain:hover:not(:disabled) { background: var(--fill-hover); color: var(--ink); }
|
|
84
|
+
@media (hover: hover) { .dn-button.is-plain:hover:not(:disabled) { background: var(--fill-hover); color: var(--ink); } }
|
|
84
85
|
.dn-button.is-danger { background: var(--danger-soft); color: var(--danger); }
|
|
85
86
|
.dn-button.is-primary.is-danger { background: var(--danger); color: var(--on-accent); }
|
|
86
87
|
|
|
@@ -104,7 +105,8 @@
|
|
|
104
105
|
cursor: pointer;
|
|
105
106
|
transition: background-color var(--duration-instant) var(--ease-standard);
|
|
106
107
|
}
|
|
107
|
-
.dn-icon-button:hover { background: var(--fill-hover); color: var(--ink); }
|
|
108
|
+
@media (hover: hover) { .dn-icon-button:hover { background: var(--fill-hover); color: var(--ink); } }
|
|
109
|
+
.dn-icon-button:active { background: var(--fill-selected); color: var(--ink); }
|
|
108
110
|
.dn-icon-button:focus-visible { outline: var(--stroke-focus) solid var(--focus-ring); outline-offset: var(--focus-offset); }
|
|
109
111
|
|
|
110
112
|
/* 点击区域:可见尺寸不变,用透明的伪元素把可点范围撑到 hit-min。
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
text-decoration: none;
|
|
40
40
|
transition: border-color var(--duration-instant) var(--ease-standard);
|
|
41
41
|
}
|
|
42
|
-
a.dn-card:hover, button.dn-card:hover { border-color: var(--line-control); }
|
|
42
|
+
@media (hover: hover) { a.dn-card:hover, button.dn-card:hover { border-color: var(--line-control); } }
|
|
43
43
|
.dn-card:focus-visible { outline: var(--stroke-focus) solid var(--focus-ring); outline-offset: var(--focus-offset); }
|
|
44
44
|
.dn-card[aria-current="true"], .dn-card.is-selected { border-color: var(--ink); }
|
|
45
45
|
.dn-card-title { margin: 0; font: var(--type-headline); }
|
|
@@ -124,7 +124,8 @@
|
|
|
124
124
|
font: var(--weight-regular) var(--type-callout-size)/1 var(--font-sans);
|
|
125
125
|
cursor: pointer;
|
|
126
126
|
}
|
|
127
|
-
.dn-meta-button:hover { background: var(--fill-hover); }
|
|
127
|
+
@media (hover: hover) { .dn-meta-button:hover { background: var(--fill-hover); } }
|
|
128
|
+
.dn-meta-button:active { background: var(--fill-selected); }
|
|
128
129
|
.dn-meta-button b { color: var(--ink); font-weight: var(--weight-regular); }
|
|
129
130
|
.dn-context-ring {
|
|
130
131
|
width: var(--icon-size-sm);
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
- 默认在触发器下方 `menu-offset` 处展开,左边对齐;从靠右的按钮弹出时,改成右边对齐。下方放不下就翻到上方;上下都放不下时限制最大高度,在面板内部滚动,并用 `overscroll-behavior: contain` 防止带动页面滚动。面板离窗口边缘至少留 `space-2`。
|
|
55
55
|
- 点击触发器打开,再点一次关闭;不要在悬停时打开。选中一项、按 Esc、点击菜单外面、焦点移出菜单,都会关闭菜单。按 Esc 关闭或选中一项后,焦点回到触发器。
|
|
56
56
|
- 右键菜单在指针位置展开,其他规则相同。
|
|
57
|
-
-
|
|
57
|
+
- 打开时淡入并从 0.96 放大到 1,用时 `duration-base`、缓动 `ease-enter`,从触发器那一侧展开;关闭时淡出并缩回 0.96,用时 `duration-instant`、缓动 `ease-exit`。中途反向(刚打开就关闭)时从当前位置退回。开启「减弱动态效果」后只保留淡入淡出。见 [动效](../foundations/motion.md)。
|
|
58
58
|
|
|
59
59
|
## 键盘
|
|
60
60
|
|
|
@@ -91,10 +91,10 @@
|
|
|
91
91
|
color: var(--ink);
|
|
92
92
|
box-shadow: var(--shadow-float);
|
|
93
93
|
transform-origin: top left;
|
|
94
|
-
transition: opacity var(--duration-
|
|
94
|
+
transition: opacity var(--duration-base) var(--ease-enter), transform var(--duration-base) var(--ease-enter), display var(--duration-base) allow-discrete;
|
|
95
95
|
}
|
|
96
|
-
.dn-menu[hidden] { display: none; }
|
|
97
|
-
@starting-style { .dn-menu { opacity: 0; transform: scale(0.
|
|
96
|
+
.dn-menu[hidden] { display: none; opacity: 0; transform: scale(0.96); transition-duration: var(--duration-instant); transition-timing-function: var(--ease-exit); }
|
|
97
|
+
@starting-style { .dn-menu:not([hidden]) { opacity: 0; transform: scale(0.96); } }
|
|
98
98
|
.dn-menu.is-glass { border-color: var(--glass-stroke); background: var(--glass-fill); -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); }
|
|
99
99
|
.dn-menu-item {
|
|
100
100
|
box-sizing: border-box;
|
|
@@ -119,6 +119,7 @@
|
|
|
119
119
|
.dn-menu-item:is([aria-checked="true"], [aria-selected="true"]) > .dn-menu-check { visibility: visible; }
|
|
120
120
|
.dn-menu-item > .dn-menu-more { width: var(--icon-size-sm); height: var(--icon-size-sm); color: var(--ink-tertiary); }
|
|
121
121
|
@media (hover: hover) { .dn-menu-item:hover { background: var(--fill-hover); } }
|
|
122
|
+
.dn-menu-item:active { background: var(--fill-selected); }
|
|
122
123
|
.dn-menu-item:focus { outline: none; }
|
|
123
124
|
.dn-menu-item:focus-visible, .dn-menu-item[aria-expanded="true"] { background: var(--fill-hover); }
|
|
124
125
|
.dn-menu-item:focus-visible { outline: var(--stroke-focus) solid var(--focus-ring); outline-offset: calc(var(--stroke-focus) * -1); }
|
|
@@ -145,7 +146,10 @@
|
|
|
145
146
|
}
|
|
146
147
|
.dn-select > :is(select, button):focus-visible { outline: none; border-color: var(--focus-ring); box-shadow: 0 0 0 3px var(--accent-soft); }
|
|
147
148
|
.dn-select > .dn-select-chevron { position: absolute; top: 50%; right: var(--field-padding-x); width: var(--icon-size-sm); height: var(--icon-size-sm); color: var(--ink-tertiary); translate: 0 -50%; pointer-events: none; }
|
|
148
|
-
@media (prefers-reduced-motion: reduce) {
|
|
149
|
+
@media (prefers-reduced-motion: reduce) {
|
|
150
|
+
.dn-menu[hidden] { transform: none; }
|
|
151
|
+
@starting-style { .dn-menu:not([hidden]) { transform: none; } }
|
|
152
|
+
}
|
|
149
153
|
@media (prefers-reduced-transparency: reduce) { .dn-menu.is-glass { background: var(--surface-raised); -webkit-backdrop-filter: none; backdrop-filter: none; } }
|
|
150
154
|
```
|
|
151
155
|
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
cursor: pointer;
|
|
46
46
|
transition: background-color var(--duration-instant) var(--ease-standard), color var(--duration-instant) var(--ease-standard);
|
|
47
47
|
}
|
|
48
|
-
.dn-segmented button:hover { color: var(--ink); }
|
|
48
|
+
@media (hover: hover) { .dn-segmented button:hover { color: var(--ink); } }
|
|
49
49
|
.dn-segmented button[aria-pressed="true"], .dn-segmented button[aria-selected="true"] { background: var(--surface-raised); color: var(--ink); box-shadow: var(--shadow-control); }
|
|
50
50
|
.dn-segmented button:focus-visible { outline: var(--stroke-focus) solid var(--focus-ring); outline-offset: var(--focus-offset); }
|
|
51
51
|
```
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
#
|
|
1
|
+
# 弹窗、抽屉与 popover
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
弹窗用于一个短小、独立的任务:确认删除、编辑一个字段、选择分享方式。它会打断当前页面,背景压暗,完成或取消之后回到原处。指针端在窗口中央弹出,触控端从底部弹出。侧边抽屉从屏幕左右边缘滑入,窄屏时用来放收起的侧边栏和侧面板。popover 附着在一个控件上,放少量说明或短表单,不打断页面。
|
|
4
4
|
|
|
5
5
|
**需要提供**:标题、一两句说明、内容(短表单或选项)、主操作和取消操作;底部弹窗还要提供支持的高度档位。危险操作要写清后果。
|
|
6
6
|
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
| 必须马上知道的错误 | 确认弹窗;SwiftUI 用 `.alert` |
|
|
15
15
|
| 一个短小、独立的任务 | 弹窗;触控端是底部弹窗 |
|
|
16
16
|
| 附着在某个控件上的少量说明、设置或短表单 | popover;手机上改成底部弹窗 |
|
|
17
|
+
| 窄屏时收起的侧边栏、侧面板 | 侧边抽屉 |
|
|
17
18
|
| 一列命令或选项 | [菜单](menu.md) |
|
|
18
19
|
| 长内容、多步流程、需要长时间阅读 | 推入新页面,或者全高弹窗 |
|
|
19
20
|
|
|
@@ -58,7 +59,7 @@
|
|
|
58
59
|
- 有未保存的输入时,Esc、取消、下拉关闭都先问「放弃修改?」,按钮是「继续编辑」和「放弃」。这是唯一可以叠在弹窗上的确认。
|
|
59
60
|
- 关闭后焦点回到打开它的控件。
|
|
60
61
|
- 表单弹窗里,回车执行主操作;危险确认除外。
|
|
61
|
-
-
|
|
62
|
+
- 动效:居中的弹窗淡入并从 0.96 放大到 1,用时 `duration-slow`、缓动 `ease-enter`;关闭时淡出并缩回 0.96,用时 `duration-base`、缓动 `ease-exit`。底部弹窗和侧边抽屉整块滑入滑出,进入 `duration-slow`、退出 `duration-base`,都用 `ease-drawer`。遮罩和面板同步淡入淡出。popover 的开合和菜单相同。开启「减弱动态效果」后只保留淡入淡出。见 [动效](../foundations/motion.md)。
|
|
62
63
|
|
|
63
64
|
## 触控端
|
|
64
65
|
|
|
@@ -67,6 +68,13 @@
|
|
|
67
68
|
- 输入框聚焦、键盘弹起时,输入框要留在可见区域,不能被键盘挡住。
|
|
68
69
|
- 手机上不用 popover,改成底部弹窗;iPad 和平板的大屏上不要弹出撑满全屏的底部弹窗。
|
|
69
70
|
|
|
71
|
+
## 侧边抽屉
|
|
72
|
+
|
|
73
|
+
- 从面板平时所在的那一边滑入:侧边栏从左边,侧面板从右边。宽度分别是 `sidebar-width` 和 `sheet-width-md`,右侧或左侧至少露出 48px 的页面,让人知道点那里能关掉。
|
|
74
|
+
- `surface` 底,靠页面一侧一条 1px `line`,阴影 `shadow-float`,背后用 `scrim` 压暗;打开时页面不能滚动。
|
|
75
|
+
- 关闭方式:点遮罩、Esc、抽屉里的关闭按钮、选中一个导航项后自动关闭。打开后焦点移进抽屉,关闭后回到打开它的按钮。
|
|
76
|
+
- 触屏上可以跟手拖动关闭:拖动时去掉过渡,让抽屉跟着手指走;松手时拖过宽度的三分之一,或者向外甩得够快,就滑出关闭,否则用 `ease-drawer` 弹回原位。
|
|
77
|
+
|
|
70
78
|
## Web
|
|
71
79
|
|
|
72
80
|
- 弹窗用 `<dialog>` 和 `showModal()`:自带顶层叠放、焦点限制在弹窗内、Esc 关闭和背景遮罩,关闭后焦点回到原来的控件。用 `aria-labelledby` 指向标题,`aria-describedby` 指向说明;危险确认用 `role="alertdialog"`。初始焦点用 `autofocus` 标出。
|
|
@@ -89,17 +97,48 @@
|
|
|
89
97
|
background: var(--surface-raised);
|
|
90
98
|
color: var(--ink);
|
|
91
99
|
box-shadow: var(--shadow-float);
|
|
92
|
-
transition: opacity var(--duration-
|
|
100
|
+
transition: opacity var(--duration-slow) var(--ease-enter), transform var(--duration-slow) var(--ease-enter), overlay var(--duration-slow) allow-discrete, display var(--duration-slow) allow-discrete;
|
|
93
101
|
}
|
|
94
|
-
dialog.dn-sheet:not([open]) { display: none; }
|
|
102
|
+
dialog.dn-sheet:not([open]) { display: none; opacity: 0; transform: scale(0.96); transition-duration: var(--duration-base); transition-timing-function: var(--ease-exit); }
|
|
95
103
|
.dn-sheet.is-sm { width: min(100% - 2 * var(--sheet-margin), var(--sheet-width-sm)); }
|
|
96
104
|
.dn-sheet.is-lg { width: min(100% - 2 * var(--sheet-margin), var(--sheet-width-lg)); }
|
|
97
|
-
.dn-sheet::backdrop { background: var(--scrim); transition: opacity var(--duration-
|
|
105
|
+
.dn-sheet::backdrop { background: var(--scrim); transition: opacity var(--duration-slow) var(--ease-enter), overlay var(--duration-slow) allow-discrete, display var(--duration-slow) allow-discrete; }
|
|
106
|
+
dialog.dn-sheet:not([open])::backdrop { opacity: 0; transition-duration: var(--duration-base); transition-timing-function: var(--ease-exit); }
|
|
98
107
|
@starting-style {
|
|
99
108
|
.dn-sheet[open] { opacity: 0; transform: scale(0.96); }
|
|
100
109
|
.dn-sheet[open]::backdrop { opacity: 0; }
|
|
101
110
|
}
|
|
102
|
-
:root:has(.dn-sheet:modal) { overflow: hidden; }
|
|
111
|
+
:root:has(:is(.dn-sheet, .dn-drawer):modal) { overflow: hidden; }
|
|
112
|
+
.dn-drawer {
|
|
113
|
+
box-sizing: border-box;
|
|
114
|
+
position: fixed;
|
|
115
|
+
inset: 0 auto 0 0;
|
|
116
|
+
width: min(var(--sidebar-width), 100vw - var(--space-12));
|
|
117
|
+
max-width: none;
|
|
118
|
+
height: 100dvh;
|
|
119
|
+
max-height: none;
|
|
120
|
+
margin: 0;
|
|
121
|
+
padding: 0;
|
|
122
|
+
overflow-y: auto;
|
|
123
|
+
overscroll-behavior: contain;
|
|
124
|
+
border: 0;
|
|
125
|
+
border-right: var(--stroke-line) solid var(--line);
|
|
126
|
+
background: var(--surface);
|
|
127
|
+
color: var(--ink);
|
|
128
|
+
box-shadow: var(--shadow-float);
|
|
129
|
+
transition: transform var(--duration-slow) var(--ease-drawer), opacity var(--duration-slow) var(--ease-drawer), overlay var(--duration-slow) allow-discrete, display var(--duration-slow) allow-discrete;
|
|
130
|
+
}
|
|
131
|
+
.dn-drawer.is-end { inset: 0 0 0 auto; width: min(var(--sheet-width-md), 100vw - var(--space-12)); border-right: 0; border-left: var(--stroke-line) solid var(--line); }
|
|
132
|
+
dialog.dn-drawer:not([open]) { display: none; transform: translateX(-100%); transition-duration: var(--duration-base); }
|
|
133
|
+
dialog.dn-drawer.is-end:not([open]) { transform: translateX(100%); }
|
|
134
|
+
.dn-drawer::backdrop { background: var(--scrim); transition: opacity var(--duration-slow) var(--ease-drawer), overlay var(--duration-slow) allow-discrete, display var(--duration-slow) allow-discrete; }
|
|
135
|
+
dialog.dn-drawer:not([open])::backdrop { opacity: 0; transition-duration: var(--duration-base); }
|
|
136
|
+
@starting-style {
|
|
137
|
+
.dn-drawer[open] { transform: translateX(-100%); }
|
|
138
|
+
.dn-drawer.is-end[open] { transform: translateX(100%); }
|
|
139
|
+
.dn-drawer[open]::backdrop { opacity: 0; }
|
|
140
|
+
}
|
|
141
|
+
.dn-drawer.is-dragging { transition: none; }
|
|
103
142
|
.dn-sheet-grabber { flex: none; width: var(--sheet-grabber-width); height: var(--sheet-grabber-height); margin: var(--space-2) auto 0; border-radius: var(--radius-full); background: var(--line-control); }
|
|
104
143
|
.dn-sheet-head { flex: none; display: flex; align-items: flex-start; gap: var(--space-3); padding: var(--sheet-padding) var(--sheet-padding) 0; }
|
|
105
144
|
.dn-sheet-heading { flex: 1; min-width: 0; }
|
|
@@ -121,23 +160,33 @@ dialog.dn-sheet:not([open]) { display: none; }
|
|
|
121
160
|
background: var(--surface-raised);
|
|
122
161
|
color: var(--ink);
|
|
123
162
|
box-shadow: var(--shadow-float);
|
|
124
|
-
|
|
163
|
+
transform-origin: top left;
|
|
164
|
+
transition: opacity var(--duration-base) var(--ease-enter), transform var(--duration-base) var(--ease-enter), overlay var(--duration-base) allow-discrete, display var(--duration-base) allow-discrete;
|
|
125
165
|
}
|
|
126
166
|
.dn-popover[popover] { inset: auto; margin: 0; }
|
|
127
|
-
|
|
167
|
+
.dn-popover[popover]:not(:popover-open) { opacity: 0; transform: scale(0.96); transition-duration: var(--duration-instant); transition-timing-function: var(--ease-exit); }
|
|
168
|
+
@starting-style { .dn-popover:popover-open { opacity: 0; transform: scale(0.96); } }
|
|
128
169
|
.dn-popover-title { margin: 0 0 var(--space-1); font: var(--type-headline); }
|
|
129
170
|
.dn-popover-text { margin: 0 0 var(--space-3); color: var(--ink-secondary); font: var(--type-callout); }
|
|
130
171
|
.dn-popover-row { display: flex; gap: var(--gap-inline); }
|
|
131
172
|
.dn-popover-row > .dn-button { flex: none; }
|
|
132
173
|
@media (pointer: coarse) {
|
|
133
|
-
.dn-sheet { transition-
|
|
174
|
+
.dn-sheet { transition-timing-function: var(--ease-drawer); }
|
|
134
175
|
.dn-sheet[open] { margin: auto auto calc(var(--sheet-margin) + env(safe-area-inset-bottom)); }
|
|
135
|
-
|
|
176
|
+
dialog.dn-sheet:not([open]) { opacity: 1; transform: translateY(calc(100% + var(--sheet-margin) + env(safe-area-inset-bottom))); transition-timing-function: var(--ease-drawer); }
|
|
177
|
+
@starting-style { .dn-sheet[open] { opacity: 1; transform: translateY(calc(100% + var(--sheet-margin) + env(safe-area-inset-bottom))); } }
|
|
136
178
|
.dn-sheet-actions { flex-direction: column-reverse; gap: var(--space-1); }
|
|
137
179
|
.dn-sheet-actions .dn-button { width: 100%; height: var(--button-height-lg); }
|
|
138
180
|
}
|
|
139
181
|
@media (pointer: fine) { .dn-sheet-grabber { display: none; } }
|
|
140
|
-
@media (prefers-reduced-motion: reduce) {
|
|
182
|
+
@media (prefers-reduced-motion: reduce) {
|
|
183
|
+
dialog.dn-sheet:not([open]), dialog.dn-drawer:not([open]) { opacity: 0; transform: none; }
|
|
184
|
+
.dn-popover[popover]:not(:popover-open) { transform: none; }
|
|
185
|
+
@starting-style {
|
|
186
|
+
.dn-sheet[open], .dn-drawer[open] { opacity: 0; transform: none; }
|
|
187
|
+
.dn-popover:popover-open { transform: none; }
|
|
188
|
+
}
|
|
189
|
+
}
|
|
141
190
|
```
|
|
142
191
|
|
|
143
192
|
```html
|
|
@@ -197,6 +246,8 @@ popover 附着在打开它的按钮下方:
|
|
|
197
246
|
|
|
198
247
|
真实页面里,popover 用 `<div class="dn-popover" popover id="share">`,打开它的按钮加 `popovertarget="share"`。
|
|
199
248
|
|
|
249
|
+
侧边抽屉用 `<dialog class="dn-drawer">`(侧面板加 `is-end`),打开时调用 `showModal()`。跟手拖动时给它加 `is-dragging`,用 `transform` 跟随手指,松手后去掉 `is-dragging`,再决定 `close()` 还是弹回。
|
|
250
|
+
|
|
200
251
|
## SwiftUI
|
|
201
252
|
|
|
202
253
|
在 iOS 26 上,中等高度的弹窗由系统渲染成悬浮的玻璃面板,拉到全高后变成不透明;Mac 上对应的是附着在窗口上的 sheet 和系统 Alert。直接用系统样式,不要用 `.presentationBackground` 盖掉它。
|
|
@@ -39,6 +39,10 @@
|
|
|
39
39
|
background: var(--surface);
|
|
40
40
|
}
|
|
41
41
|
.dn-sidebar-section, .dn-sidebar-group { display: flex; flex-direction: column; gap: var(--row-gap); }
|
|
42
|
+
.dn-sidebar-group { interpolate-size: allow-keywords; }
|
|
43
|
+
.dn-sidebar-group::details-content { display: flex; flex-direction: column; gap: var(--row-gap); block-size: 0; overflow: clip; transition: block-size var(--duration-base) var(--ease-standard), content-visibility var(--duration-base) allow-discrete; }
|
|
44
|
+
.dn-sidebar-group[open]::details-content { block-size: auto; }
|
|
45
|
+
@media (prefers-reduced-motion: reduce) { .dn-sidebar-group::details-content { transition: none; } }
|
|
42
46
|
.dn-sidebar-heading {
|
|
43
47
|
display: flex;
|
|
44
48
|
align-items: center;
|
|
@@ -54,7 +58,7 @@
|
|
|
54
58
|
.dn-sidebar-heading::-webkit-details-marker { display: none; }
|
|
55
59
|
.dn-sidebar-heading .dn-icon { width: var(--icon-size-sm); height: var(--icon-size-sm); transition: transform var(--duration-base) var(--ease-standard); }
|
|
56
60
|
.dn-sidebar-group:not([open]) > .dn-sidebar-heading .dn-icon { transform: rotate(-90deg); }
|
|
57
|
-
.dn-sidebar-heading:hover { color: var(--ink-secondary); }
|
|
61
|
+
@media (hover: hover) { .dn-sidebar-heading:hover { color: var(--ink-secondary); } }
|
|
58
62
|
.dn-sidebar-item {
|
|
59
63
|
display: flex;
|
|
60
64
|
align-items: center;
|
|
@@ -70,7 +74,8 @@
|
|
|
70
74
|
}
|
|
71
75
|
.dn-sidebar-group .dn-sidebar-item { font: var(--type-callout); }
|
|
72
76
|
.dn-sidebar-item .dn-icon { width: var(--icon-size-md); height: var(--icon-size-md); color: var(--ink-secondary); }
|
|
73
|
-
.dn-sidebar-item:hover { background: var(--fill-hover); }
|
|
77
|
+
@media (hover: hover) { .dn-sidebar-item:hover { background: var(--fill-hover); } }
|
|
78
|
+
.dn-sidebar-item:active { background: var(--fill-selected); }
|
|
74
79
|
.dn-sidebar-item[aria-current="page"] { background: var(--fill-selected); }
|
|
75
80
|
.dn-sidebar-item:focus-visible { outline: var(--stroke-focus) solid var(--focus-ring); outline-offset: calc(var(--focus-offset) * -1); }
|
|
76
81
|
.dn-sidebar-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
@@ -15,10 +15,13 @@ AI 在对话里做了什么。分两层:平时只显示一行灰色的**摘要
|
|
|
15
15
|
|
|
16
16
|
## Web
|
|
17
17
|
|
|
18
|
-
用 `<details>` 和 `<summary>`
|
|
18
|
+
用 `<details>` 和 `<summary>` 实现展开,键盘和读屏都能用;内容高度和箭头一起用 `duration-base` 过渡,见 [动效](../foundations/motion.md)。
|
|
19
19
|
|
|
20
20
|
```css
|
|
21
|
-
.dn-activity { min-width: 0; }
|
|
21
|
+
.dn-activity { min-width: 0; interpolate-size: allow-keywords; }
|
|
22
|
+
.dn-activity::details-content { block-size: 0; overflow: clip; transition: block-size var(--duration-base) var(--ease-standard), content-visibility var(--duration-base) allow-discrete; }
|
|
23
|
+
.dn-activity[open]::details-content { block-size: auto; }
|
|
24
|
+
@media (prefers-reduced-motion: reduce) { .dn-activity::details-content { transition: none; } }
|
|
22
25
|
.dn-activity > summary {
|
|
23
26
|
display: flex;
|
|
24
27
|
align-items: center;
|
|
@@ -30,7 +33,7 @@ AI 在对话里做了什么。分两层:平时只显示一行灰色的**摘要
|
|
|
30
33
|
cursor: pointer;
|
|
31
34
|
}
|
|
32
35
|
.dn-activity > summary::-webkit-details-marker { display: none; }
|
|
33
|
-
.dn-activity > summary:hover { color: var(--ink); }
|
|
36
|
+
@media (hover: hover) { .dn-activity > summary:hover { color: var(--ink); } }
|
|
34
37
|
.dn-activity > summary .dn-icon { width: var(--icon-size-sm); height: var(--icon-size-sm); transition: transform var(--duration-base) var(--ease-standard); }
|
|
35
38
|
.dn-activity[open] > summary .dn-icon { transform: rotate(90deg); }
|
|
36
39
|
.dn-activity > .dn-steps { margin: var(--space-1) 0 0 6px; padding-left: var(--space-3); border-left: var(--stroke-line) solid var(--line); }
|
|
@@ -50,7 +53,8 @@ AI 在对话里做了什么。分两层:平时只显示一行灰色的**摘要
|
|
|
50
53
|
}
|
|
51
54
|
.dn-step::-webkit-details-marker { display: none; }
|
|
52
55
|
details > .dn-step { cursor: pointer; }
|
|
53
|
-
.dn-step:hover { background: var(--fill-hover); }
|
|
56
|
+
@media (hover: hover) { .dn-step:hover { background: var(--fill-hover); } }
|
|
57
|
+
.dn-step:active { background: var(--fill-selected); }
|
|
54
58
|
.dn-step .dn-icon { width: var(--icon-size-md); height: var(--icon-size-md); }
|
|
55
59
|
.dn-step-name { color: var(--ink); font-weight: var(--weight-semibold); white-space: nowrap; }
|
|
56
60
|
.dn-step-summary { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: var(--weight-regular) var(--type-caption-size)/1 var(--font-mono); }
|
|
@@ -16,14 +16,14 @@ Web 应用的页面骨架:左边是[侧边栏](sidebar.md),中间是内容
|
|
|
16
16
|
- **顶栏**:高 `toolbar-height`,`canvas` 底,不透明,底部一条 1px `line`,吸顶(`z-sticky`)。左边是标题(`toolbar-title-size`、常规字重、`ink`,后面一个小箭头,点开是菜单);右边是一排[图标按钮](button.md),间隔 2,打开状态的按钮用 `fill-selected` 底。
|
|
17
17
|
- **内容区**:对话栏宽度不超过 `content-max`,左右至少留 `page-margin` + `space-3`;Composer 左右留 `page-margin`。
|
|
18
18
|
- **侧面板**:左边一条 1px `line`,`canvas` 底,默认占内容区的 40%,可以拖动。顶部是标签头,和顶栏同高、底部同样一条线;标签高 `panel-tab-height`,当前标签 `fill-selected` 底、`ink` 字,其他 `ink-secondary`;最右边是关闭、放大按钮。
|
|
19
|
-
- 窄于 `breakpoint-md`(960
|
|
19
|
+
- 窄于 `breakpoint-md`(960)时侧面板改成覆盖在内容上的[抽屉](sheet.md);窄于 `breakpoint-sm`(760)时侧边栏也收起成抽屉。
|
|
20
20
|
|
|
21
21
|
## Web
|
|
22
22
|
|
|
23
23
|
```css
|
|
24
24
|
.dn-app { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; height: 100dvh; background: var(--canvas); color: var(--ink); }
|
|
25
25
|
.dn-app-main { display: flex; flex-direction: column; min-width: 0; min-height: 0; }
|
|
26
|
-
.dn-app-content { flex: 1; min-height: 0; overflow: auto; padding: var(--space-6) calc(var(--page-margin) + var(--space-3)); }
|
|
26
|
+
.dn-app-content { flex: 1; min-height: 0; overflow: auto; scrollbar-gutter: stable; padding: var(--space-6) calc(var(--page-margin) + var(--space-3)); }
|
|
27
27
|
|
|
28
28
|
.dn-toolbar {
|
|
29
29
|
position: sticky;
|
|
@@ -53,7 +53,8 @@ Web 应用的页面骨架:左边是[侧边栏](sidebar.md),中间是内容
|
|
|
53
53
|
}
|
|
54
54
|
.dn-toolbar-title span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
55
55
|
.dn-toolbar-title .dn-icon { width: var(--icon-size-sm); height: var(--icon-size-sm); color: var(--ink-secondary); }
|
|
56
|
-
.dn-toolbar-title:hover { background: var(--fill-hover); }
|
|
56
|
+
@media (hover: hover) { .dn-toolbar-title:hover { background: var(--fill-hover); } }
|
|
57
|
+
.dn-toolbar-title:active { background: var(--fill-selected); }
|
|
57
58
|
.dn-toolbar-actions { display: flex; align-items: center; gap: 2px; margin-left: auto; }
|
|
58
59
|
.dn-toolbar .dn-icon-button[aria-pressed="true"], .dn-panel-tabs .dn-icon-button[aria-pressed="true"] { background: var(--fill-selected); color: var(--ink); }
|
|
59
60
|
|
|
@@ -77,7 +78,7 @@ Web 应用的页面骨架:左边是[侧边栏](sidebar.md),中间是内容
|
|
|
77
78
|
white-space: nowrap;
|
|
78
79
|
cursor: pointer;
|
|
79
80
|
}
|
|
80
|
-
.dn-panel-tabs [role="tab"]:hover { color: var(--ink); }
|
|
81
|
+
@media (hover: hover) { .dn-panel-tabs [role="tab"]:hover { color: var(--ink); } }
|
|
81
82
|
.dn-panel-tabs [role="tab"][aria-selected="true"] { background: var(--fill-selected); color: var(--ink); }
|
|
82
83
|
.dn-panel-tabs .dn-panel-actions { display: flex; gap: 2px; margin-left: auto; }
|
|
83
84
|
.dn-panel-body { flex: 1; min-height: 0; overflow: auto; padding: var(--space-3) var(--space-4); font: var(--type-code); color: var(--ink-secondary); }
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
- **键盘**:控件获得键盘焦点(`:focus-visible`)时立即显示,失去焦点时关闭。
|
|
52
52
|
- **关闭**:指针离开控件和 tooltip、按 Esc、按下或点击控件、页面滚动时关闭。指针可以从控件移到 tooltip 上而不关闭(离开后留 100ms 的余地),方便放大镜用户阅读。
|
|
53
53
|
- 同一时间最多显示一个 tooltip。
|
|
54
|
-
-
|
|
54
|
+
- 动效:淡入淡出,都用 `duration-instant`(进入 `ease-enter`,退出 `ease-exit`),不位移、不缩放。
|
|
55
55
|
- 悬停卡片:同样停留 `tooltip-delay` 后显示;指针移到卡片上不关闭,离开控件和卡片 300ms 后关闭。卡片里的内容只是预览,点击链接本身就能看到全部,所以键盘和触屏用户不会因为看不到卡片而缺少信息。
|
|
56
56
|
|
|
57
57
|
## 触控端
|
|
@@ -91,9 +91,10 @@
|
|
|
91
91
|
box-shadow: var(--shadow-float);
|
|
92
92
|
font: var(--weight-regular) var(--tooltip-font-size)/var(--type-caption-line) var(--font-sans);
|
|
93
93
|
overflow-wrap: anywhere;
|
|
94
|
-
transition: opacity var(--duration-instant) var(--ease-
|
|
94
|
+
transition: opacity var(--duration-instant) var(--ease-enter), overlay var(--duration-instant) allow-discrete, display var(--duration-instant) allow-discrete;
|
|
95
95
|
}
|
|
96
96
|
.dn-tooltip[popover] { position: fixed; inset: auto; }
|
|
97
|
+
.dn-tooltip[popover]:not(:popover-open) { display: none; opacity: 0; transition-timing-function: var(--ease-exit); }
|
|
97
98
|
@starting-style { .dn-tooltip:popover-open { opacity: 0; } }
|
|
98
99
|
.dn-tooltip-shortcut { margin-inline-start: var(--space-2); color: color-mix(in srgb, var(--on-accent) 70%, transparent); font-variant-numeric: tabular-nums; }
|
|
99
100
|
.dn-tooltip-anchor { position: relative; display: inline-flex; }
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
|
|
46
46
|
## 交互底色
|
|
47
47
|
|
|
48
|
-
悬停用 `fill-hover`,选中用 `fill-selected`。它们是半透明的黑或白,叠在任何纸面上都成立,所以列表行、侧边栏项、次按钮、文字按钮都用它们,而不是另选一个灰色。选中要一眼看得出来:`fill-selected`
|
|
48
|
+
悬停用 `fill-hover`,选中用 `fill-selected`。它们是半透明的黑或白,叠在任何纸面上都成立,所以列表行、侧边栏项、次按钮、文字按钮都用它们,而不是另选一个灰色。选中要一眼看得出来:`fill-selected` 比悬停明显得多。每种元素在悬停、按下、选中时怎么变,见 [交互状态](states.md)。
|
|
49
49
|
|
|
50
50
|
## 仅网页
|
|
51
51
|
|
|
@@ -57,6 +57,19 @@
|
|
|
57
57
|
- 弹出层和面板内部滚动时加 `overscroll-behavior: contain`,不带动后面的页面。
|
|
58
58
|
- 跳到页面锚点时,用 `scroll-padding-top` 留出吸顶元素的高度。
|
|
59
59
|
|
|
60
|
+
## 滚动条
|
|
61
|
+
|
|
62
|
+
- **Web 统一用一种细滚动条。** 所有滚动区域,包括页面本身,都用同一种:占 `scrollbar-size`(10px),中间是 `scrollbar-thumb-size`(6px)粗的胶囊滑块,`line-control` 色,悬停和拖动时变 `ink-tertiary`;轨道透明,没有两端的箭头按钮。滑块颜色来自 token,深色主题自动跟着换。
|
|
63
|
+
- **在全局样式里写一次。** 用下面的 CSS:Chrome、Edge、Safari 用 `::-webkit-scrollbar` 画,Firefox 用 `scrollbar-width: thin` 加 `scrollbar-color`。Chrome 里一个元素只要设置了 `scrollbar-width` 或 `scrollbar-color`,`::-webkit-scrollbar` 对它就失效,所以除了隐藏滚动条,组件里不要再写这两个属性,也不要给某个组件单独写一套滚动条或用脚本模拟。
|
|
64
|
+
- **SwiftUI 用系统滚动条。** 原生端的滚动指示条跟随用户的系统设置,不要改。
|
|
65
|
+
- **预留位置,不让内容跳动。** 整页滚动的页面(文档、官网)在 `html` 上设 `scrollbar-gutter: stable`;内容居中的主滚动区域(对话栏)也一样。这样内容从不满一屏变成超出一屏、或者弹窗打开锁住页面滚动时,滚动条出现和消失不会让内容左右跳。应用里页面本身不滚动,不要在 `html` 上预留。
|
|
66
|
+
- **内容别被压住。** 滚动区域的内边距写在滚动容器里面,右侧至少留 `space-2`,滚动时才浮现的滚动条不会盖住文字。
|
|
67
|
+
- **能隐藏的只有横向滑动条。** 标签、筛选项、卡片排成一行横向滑动时,可以隐藏滚动条(`scrollbar-width: none`),但必须有提示:末端渐隐、露出半张卡片,或者左右翻页按钮;滚到底时去掉渐隐。纵向的滚动区域一律不隐藏滚动条。
|
|
68
|
+
- **键盘也能滚。** 滚动区域里没有可以聚焦的内容时,给容器加 `tabindex="0"`、`role="region"` 和 `aria-label`,键盘用户才能用方向键滚动它,读屏也能读出它是什么。
|
|
69
|
+
- **把宽度算进去。** 细滚动条在 Chrome、Edge、Safari 里一直显示,占 `scrollbar-size`,在 Mac 上也不再是滚动时才浮现;Firefox 跟随系统设置。栏宽、居中和右侧留白按占位的情况检查:内容不被挤窄,没有多出来的横向滚动条。
|
|
70
|
+
- **滚动不加戏。** 不接管滚轮和触控板,不改滚动速度和惯性;`scroll-behavior: smooth` 只用于页内锚点跳转,开启「减弱动态效果」时去掉;吸附滚动(`scroll-snap`)只用于横向卡片条。
|
|
71
|
+
- **提示还有内容,用线不用阴影。** 顶栏、表头本来就有 1px 底线;弹窗、面板这类平时没有分隔线的区域,内容能滚动时才加上(见[弹窗](../components/sheet.md))。不在内容边缘加阴影或渐变遮罩,横向滑动条的末端渐隐除外。
|
|
72
|
+
|
|
60
73
|
## 响应式
|
|
61
74
|
|
|
62
75
|
- `breakpoint-sm`(760)以下侧边栏收起成抽屉,多栏变单栏;`breakpoint-md`(960)以下侧面板变成抽屉,目录这类次要栏收起;`breakpoint-lg`(1280)以上内容不再变宽,两侧留白。断点只能写在媒体查询里,没有对应的 CSS 变量。
|
|
@@ -71,6 +84,11 @@
|
|
|
71
84
|
.dn-column.is-form { width: min(100%, var(--form-max) + 2 * var(--page-margin)); }
|
|
72
85
|
.dn-column.is-wide { width: min(100%, var(--wide-max) + 2 * var(--page-margin)); }
|
|
73
86
|
.dn-scroll { min-height: 0; overflow-y: auto; overscroll-behavior: contain; }
|
|
87
|
+
.dn-scroll.is-stable { scrollbar-gutter: stable; }
|
|
88
|
+
.dn-scroll-x { display: flex; gap: var(--gap-inline); overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: none; scroll-snap-type: x proximity; }
|
|
89
|
+
.dn-scroll-x::-webkit-scrollbar { display: none; }
|
|
90
|
+
.dn-scroll-x > * { flex: none; scroll-snap-align: start; }
|
|
91
|
+
.dn-scroll-x:not(.is-end) { mask-image: linear-gradient(to left, transparent, var(--ink) var(--space-8)); }
|
|
74
92
|
.dn-baseline { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--space-2); }
|
|
75
93
|
.dn-baseline-title { margin: 0; font: var(--type-title); }
|
|
76
94
|
.dn-baseline-meta { color: var(--ink-tertiary); font: var(--type-footnote); font-variant-numeric: tabular-nums; }
|
|
@@ -85,6 +103,16 @@
|
|
|
85
103
|
.dn-item.is-top > .dn-item-end { margin-top: calc((var(--type-body-line) - var(--button-height-sm)) / 2); }
|
|
86
104
|
.dn-centered { display: grid; grid-template-rows: 2fr auto 3fr; justify-items: center; min-height: 100%; text-align: center; }
|
|
87
105
|
.dn-centered > * { grid-row: 2; }
|
|
106
|
+
@supports selector(::-webkit-scrollbar) {
|
|
107
|
+
::-webkit-scrollbar { width: var(--scrollbar-size); height: var(--scrollbar-size); }
|
|
108
|
+
::-webkit-scrollbar-track, ::-webkit-scrollbar-corner { background: transparent; }
|
|
109
|
+
::-webkit-scrollbar-thumb { border: calc((var(--scrollbar-size) - var(--scrollbar-thumb-size)) / 2) solid transparent; border-radius: var(--radius-full); background: var(--line-control) padding-box; }
|
|
110
|
+
::-webkit-scrollbar-thumb:hover, ::-webkit-scrollbar-thumb:active { background-color: var(--ink-tertiary); }
|
|
111
|
+
::-webkit-scrollbar-button { display: none; }
|
|
112
|
+
}
|
|
113
|
+
@supports not selector(::-webkit-scrollbar) {
|
|
114
|
+
* { scrollbar-width: thin; scrollbar-color: var(--line-control) transparent; }
|
|
115
|
+
}
|
|
88
116
|
```
|
|
89
117
|
|
|
90
118
|
不同字号并排,按基线对齐:
|
|
@@ -134,9 +162,28 @@
|
|
|
134
162
|
</div>
|
|
135
163
|
```
|
|
136
164
|
|
|
165
|
+
滚动区域用细滚动条;横向滑动条隐藏滚动条、末端渐隐:
|
|
166
|
+
|
|
167
|
+
```html
|
|
168
|
+
<div class="dn-scroll" tabindex="0" role="region" aria-label="最近的会话">
|
|
169
|
+
<p class="dn-item-title">整理周会记录</p>
|
|
170
|
+
<p class="dn-item-title">修复登录页的焦点顺序</p>
|
|
171
|
+
<p class="dn-item-title">起草发布说明</p>
|
|
172
|
+
<p class="dn-item-title">对比三种排版方案</p>
|
|
173
|
+
<p class="dn-item-title">检查深色主题的对比度</p>
|
|
174
|
+
<p class="dn-item-title">补全弹窗的键盘操作</p>
|
|
175
|
+
<p class="dn-item-title">写 tooltip 的测试</p>
|
|
176
|
+
<p class="dn-item-title">更新安装说明</p>
|
|
177
|
+
<p class="dn-item-title">导出图标</p>
|
|
178
|
+
</div>
|
|
179
|
+
<div class="dn-scroll-x" tabindex="0" role="region" aria-label="筛选">
|
|
180
|
+
<span class="dn-tag">全部</span><span class="dn-tag">今天</span><span class="dn-tag">本周</span><span class="dn-tag">已完成</span><span class="dn-tag">进行中</span><span class="dn-tag">有附件</span><span class="dn-tag">我创建的</span><span class="dn-tag">共享给我</span><span class="dn-tag">已归档</span><span class="dn-tag">草稿</span>
|
|
181
|
+
</div>
|
|
182
|
+
```
|
|
183
|
+
|
|
137
184
|
## SwiftUI
|
|
138
185
|
|
|
139
|
-
`Form`、`List`、`NavigationSplitView`
|
|
186
|
+
`Form`、`List`、`NavigationSplitView` 自带对齐和滚动,不要改;滚动指示条保持系统默认,跟随用户的系统设置。只有横向卡片条可以用 `.scrollIndicators(.hidden)`,并露出半张卡片提示还能滑动。自己排的内容按下面写:
|
|
140
187
|
|
|
141
188
|
```swift
|
|
142
189
|
// 图标和文字:用 Label,图标会对齐到第一行文字
|
|
@@ -170,6 +217,17 @@ ScrollView {
|
|
|
170
217
|
.frame(maxWidth: .infinity)
|
|
171
218
|
}
|
|
172
219
|
|
|
220
|
+
// 横向卡片条:隐藏滚动条,按卡片吸附,露出下一张的一部分
|
|
221
|
+
ScrollView(.horizontal) {
|
|
222
|
+
LazyHStack(spacing: Deernova.cardGap) {
|
|
223
|
+
ForEach(notes) { NoteCard(note: $0).frame(width: 240) }
|
|
224
|
+
}
|
|
225
|
+
.scrollTargetLayout()
|
|
226
|
+
}
|
|
227
|
+
.scrollIndicators(.hidden)
|
|
228
|
+
.scrollTargetBehavior(.viewAligned)
|
|
229
|
+
.contentMargins(.horizontal, Deernova.pageMargin)
|
|
230
|
+
|
|
173
231
|
// 空状态:系统组件已经处理了视觉居中
|
|
174
232
|
ContentUnavailableView("还没有笔记", systemImage: "doc", description: Text("点右上角的加号新建一条。"))
|
|
175
233
|
```
|
|
@@ -183,4 +241,5 @@ ContentUnavailableView("还没有笔记", systemImage: "doc", description: Text(
|
|
|
183
241
|
- 把空状态贴在顶部,或者正好放在几何中心。
|
|
184
242
|
- 把正文拉满宽屏;正文和普通标题居中排版。
|
|
185
243
|
- 页面和里面的栏同时滚动;同一方向嵌套滚动;页面出现横向滚动。
|
|
244
|
+
- 给单个组件另写一套滚动条,或者用脚本模拟滚动条;隐藏纵向滚动区域的滚动条;接管滚轮或改滚动速度;在应用的 `html` 上预留滚动条位置。
|
|
186
245
|
- 按设备型号写死布局;用可用宽度和尺寸类别判断。
|
|
@@ -1,22 +1,124 @@
|
|
|
1
1
|
# 动效
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
动效用来说明因果:东西从哪里来、到哪里去。打开有过程,关闭也有过程;起步要快,停下要柔。数值见 [设计变量](../tokens.md) 的「动效」。
|
|
4
4
|
|
|
5
5
|
| Token | 值 | 用途 |
|
|
6
6
|
| --- | --- | --- |
|
|
7
|
-
| `duration-instant` | 120ms |
|
|
8
|
-
| `duration-base` | 200ms |
|
|
9
|
-
| `duration-slow` | 320ms |
|
|
10
|
-
| `ease-standard` | cubic-bezier(0.2, 0.8, 0.2, 1) |
|
|
7
|
+
| `duration-instant` | 120ms | 悬停、按压、选中;tooltip 的进出;菜单、popover 的退出 |
|
|
8
|
+
| `duration-base` | 200ms | 菜单、popover、轻提示的进入;展开收起;弹窗和抽屉的退出 |
|
|
9
|
+
| `duration-slow` | 320ms | 弹窗、底部弹窗、侧边抽屉的进入,页面级转场 |
|
|
10
|
+
| `ease-standard` | cubic-bezier(0.2, 0.8, 0.2, 1) | 状态变化和展开收起:颜色、高度、箭头旋转 |
|
|
11
|
+
| `ease-enter` | cubic-bezier(0.16, 1, 0.3, 1) | 出现:起步快、停得很柔 |
|
|
12
|
+
| `ease-exit` | cubic-bezier(0.4, 0, 0.2, 1) | 消失:配比进入低一档的时长 |
|
|
13
|
+
| `ease-drawer` | cubic-bezier(0.32, 0.72, 0, 1) | 从屏幕边缘滑入滑出的面板,进出都用它 |
|
|
11
14
|
|
|
12
|
-
|
|
13
|
-
- Web 上 `transition: background-color var(--duration-instant) var(--ease-standard)`。只过渡颜色、不透明度和 transform,不过渡 width、height。
|
|
15
|
+
## 进入和退出
|
|
14
16
|
|
|
15
|
-
|
|
17
|
+
| 元素 | 进入 | 退出 | 动什么 |
|
|
18
|
+
| --- | --- | --- | --- |
|
|
19
|
+
| Tooltip | `duration-instant` · `ease-enter` | `duration-instant` · `ease-exit` | 透明度 |
|
|
20
|
+
| 菜单、下拉选择、popover、悬停卡片 | `duration-base` · `ease-enter` | `duration-instant` · `ease-exit` | 透明度,加上从触发器那一侧 0.96 → 1 的缩放 |
|
|
21
|
+
| 轻提示 | `duration-base` · `ease-enter` | `duration-instant` · `ease-exit` | 透明度,加上向上 8px 的位移 |
|
|
22
|
+
| 居中弹窗 | `duration-slow` · `ease-enter` | `duration-base` · `ease-exit` | 面板的透明度和 0.96 → 1 的缩放;遮罩的透明度 |
|
|
23
|
+
| 底部弹窗 | `duration-slow` · `ease-drawer` | `duration-base` · `ease-drawer` | 从底边整块滑入;遮罩的透明度 |
|
|
24
|
+
| 侧边抽屉 | `duration-slow` · `ease-drawer` | `duration-base` · `ease-drawer` | 从左边或右边整块滑入;遮罩的透明度 |
|
|
25
|
+
| 展开收起(侧边栏分组、执行步骤、目录) | `duration-base` · `ease-standard` | 同进入 | 内容高度,箭头同步旋转 |
|
|
26
|
+
| 悬停、按下、选中 | `duration-instant` · `ease-standard` | 同进入 | 颜色,见 [交互状态](states.md) |
|
|
27
|
+
|
|
28
|
+
## 原则
|
|
29
|
+
|
|
30
|
+
- **一开一关都要有过程。** 只有出现动画、消失时一下子不见,是最常见的生硬感来源。
|
|
31
|
+
- **退出比进入快一档。** 用户要关掉的东西不该拖住他:进入 `duration-slow` 的,退出用 `duration-base`;进入 `duration-base` 的,退出用 `duration-instant`。
|
|
32
|
+
- **进入用 `ease-enter`。** 前一半时间就完成大部分位移,看起来立即响应,结尾又很柔。抽屉和底部弹窗用 `ease-drawer`,整块面板像被推出来一样顺。
|
|
33
|
+
- **距离要短。** 浮层从触发器旁边长出来:缩放不小于 0.96,位移不超过 8px;只有抽屉和底部弹窗走完整的屏幕边距离。不要从远处飞入,不要回弹、晃动。
|
|
34
|
+
- **一起动的东西同步。** 遮罩和面板用同样的时长和曲线;面板里的内容跟着面板一起出现,应用里不做逐个错开的入场。
|
|
35
|
+
- **可以中途反向。** 刚打开就关闭、刚关闭又打开时,从当前位置平滑退回,不跳到起点。
|
|
36
|
+
- **保持 60fps。** 浮层只动 `transform` 和 `opacity`;展开收起是唯一允许动高度的情况。不在动画里改宽度、位置、阴影、模糊。内容超过一屏的区域不做高度动画,直接展开。
|
|
37
|
+
|
|
38
|
+
## Web 写法
|
|
39
|
+
|
|
40
|
+
- 开合用 CSS transition,不用 `@keyframes`:transition 中途反向时会从当前值退回,keyframes 会跳。
|
|
41
|
+
- 打开的样子写在元素默认样式里,关闭的样子写在关闭状态的选择器上(`[hidden]`、`dialog:not([open])`、`[popover]:not(:popover-open)`),并在那里换成退出的时长和曲线。浏览器按「变成哪个状态」选用 transition,所以进出可以各用各的。
|
|
42
|
+
- 出现前的样子写在 `@starting-style` 里,元素从 `display: none` 出来时从这里开始过渡。
|
|
43
|
+
- 关闭时元素要变回 `display: none`、离开顶层:给 `display` 加 `allow-discrete`(`<dialog>` 和 popover 还要给 `overlay` 加),退出动画才能播完。不支持的浏览器退化为直接消失,功能不受影响。
|
|
44
|
+
- 展开收起:`<details>` 用 `::details-content` 加 `interpolate-size: allow-keywords` 过渡高度;其他容器用 `grid-template-rows` 从 `0fr` 到 `1fr`。
|
|
45
|
+
- 不用 `setTimeout` 按时长删除元素;要等退出结束时监听 `transitionend`。`will-change` 只在动画进行时加。
|
|
46
|
+
- 检查:在浏览器性能面板里录一次开合,没有掉帧;在动画中途反向操作,能平滑退回。
|
|
47
|
+
|
|
48
|
+
各组件的完整写法见 [菜单](../components/menu.md)、[弹窗、抽屉与 popover](../components/sheet.md)、[Tooltip](../components/tooltip.md)、[横幅与轻提示](../components/banner.md)。下面是展开收起的通用写法:
|
|
49
|
+
|
|
50
|
+
```css
|
|
51
|
+
.dn-collapse { interpolate-size: allow-keywords; }
|
|
52
|
+
.dn-collapse::details-content { block-size: 0; overflow: clip; transition: block-size var(--duration-base) var(--ease-standard), content-visibility var(--duration-base) allow-discrete; }
|
|
53
|
+
.dn-collapse[open]::details-content { block-size: auto; }
|
|
54
|
+
.dn-collapse > summary { list-style: none; }
|
|
55
|
+
.dn-collapse > summary::-webkit-details-marker { display: none; }
|
|
56
|
+
.dn-collapse-grid { display: grid; grid-template-rows: 0fr; transition: grid-template-rows var(--duration-base) var(--ease-standard); }
|
|
57
|
+
.dn-collapse-grid.is-open { grid-template-rows: 1fr; }
|
|
58
|
+
.dn-collapse-grid > * { min-height: 0; overflow: hidden; }
|
|
59
|
+
@media (prefers-reduced-motion: reduce) { .dn-collapse::details-content, .dn-collapse-grid { transition: none; } }
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
```html
|
|
63
|
+
<details class="dn-collapse">
|
|
64
|
+
<summary class="dn-state dn-state-row">本周的会话(点开试试)</summary>
|
|
65
|
+
<div class="dn-state dn-state-row">整理周会记录</div>
|
|
66
|
+
<div class="dn-state dn-state-row">修复登录页的焦点顺序</div>
|
|
67
|
+
<div class="dn-state dn-state-row">起草发布说明</div>
|
|
68
|
+
</details>
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## 减弱动态效果
|
|
72
|
+
|
|
73
|
+
- 用户开启「减弱动态效果」后,去掉位移和缩放,只保留透明度的淡入淡出,时长不超过 `duration-base`;抽屉和底部弹窗也改成淡入淡出。
|
|
74
|
+
- 展开收起直接切换;转圈这类循环放慢,不再闪烁。
|
|
75
|
+
- 完整信息不能依赖动画才看得到。
|
|
76
|
+
|
|
77
|
+
## 其他规则
|
|
16
78
|
|
|
17
79
|
- 业务界面里不做无目的的循环动画。品牌展示页可以有叙事动效,但要能暂停,也不能妨碍阅读。
|
|
18
|
-
- 用户开启「减弱动态效果」后,位移和缩放换成淡入淡出,完整信息不能依赖动画才看得到。
|
|
19
80
|
- **布局保持稳定**:给会变化的标题、状态和按钮文字预留空间,切换时阅读位置和按钮位置不跳动。禁用的按钮保持原位,不隐藏。加载时按钮宽度不变,把文字换成转圈。
|
|
20
81
|
- **状态不丢失**:重绘、切换主题、窗口缩放时,焦点、选中项和用户输入都要保留。
|
|
21
82
|
- **中断可恢复**:装饰性动画在暂停、离开视野或页面隐藏时冻结,回来后从原处继续,不追赶错过的时间;和真实时间绑定的功能(倒计时等)仍以真实时间为准。
|
|
22
83
|
- 连续的装饰动画在 Web 上用 `requestAnimationFrame` 按时间推进,`visibilitychange` 时暂停。
|
|
84
|
+
|
|
85
|
+
## SwiftUI
|
|
86
|
+
|
|
87
|
+
系统的 `.sheet`、`.popover`、`Menu`、`NavigationStack`、`DisclosureGroup` 自带进出动画,不要另加。自己画的浮层和面板:出现和消失都给 `transition`,动画挂在状态上。默认用 `.smooth`,小浮层用 `.snappy`。
|
|
88
|
+
|
|
89
|
+
```swift
|
|
90
|
+
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
|
91
|
+
|
|
92
|
+
// 小浮层:从触发器一侧长出来
|
|
93
|
+
ZStack(alignment: .topLeading) {
|
|
94
|
+
if isOpen {
|
|
95
|
+
FilterPanel()
|
|
96
|
+
.transition(reduceMotion ? .opacity : .opacity.combined(with: .scale(scale: 0.96, anchor: .topLeading)))
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
.animation(.snappy(duration: Deernova.durationBase), value: isOpen)
|
|
100
|
+
|
|
101
|
+
// 侧边抽屉:遮罩和面板一起动
|
|
102
|
+
ZStack(alignment: .leading) {
|
|
103
|
+
if showDrawer {
|
|
104
|
+
Color.scrim.ignoresSafeArea()
|
|
105
|
+
.onTapGesture { showDrawer = false }
|
|
106
|
+
.transition(.opacity)
|
|
107
|
+
DrawerView()
|
|
108
|
+
.frame(width: Deernova.sidebarWidth)
|
|
109
|
+
.transition(reduceMotion ? .opacity : .move(edge: .leading))
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
.animation(.smooth(duration: Deernova.durationSlow), value: showDrawer)
|
|
113
|
+
|
|
114
|
+
// 展开收起
|
|
115
|
+
withAnimation(.smooth(duration: Deernova.durationBase)) { isExpanded.toggle() }
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## 不要
|
|
119
|
+
|
|
120
|
+
- 只做出现动画,关闭时一下子消失;退出比进入还慢。
|
|
121
|
+
- 用 `@keyframes` 做开合,中途反向时跳回起点。
|
|
122
|
+
- 在动画里改宽度、位置、阴影或模糊;对超过一屏的内容做高度动画。
|
|
123
|
+
- 从远处飞入、缩放小于 0.96、回弹或晃动;应用里让列表项逐个错开入场。
|
|
124
|
+
- 用 `setTimeout` 按时长删除元素;遮罩和面板各用一套时长。
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# 交互状态
|
|
2
|
+
|
|
3
|
+
悬停、按下、选中、焦点、禁用分别怎么表现。颜色只有两个半透明的底色:悬停 `fill-hover`,按下和选中 `fill-selected`,数值见 [设计变量](../tokens.md) 的「颜色」。
|
|
4
|
+
|
|
5
|
+
## 各元素的状态
|
|
6
|
+
|
|
7
|
+
| 元素 | 悬停 | 按下 | 选中(当前项) |
|
|
8
|
+
| --- | --- | --- | --- |
|
|
9
|
+
| 文字按钮、图标按钮、工具栏按钮 | `fill-hover` 底,文字和图标从 `ink-secondary` 变 `ink` | `fill-selected` 底 | `fill-selected` 底、`ink`(`aria-pressed`) |
|
|
10
|
+
| 次按钮(灰底) | 在 `fill-selected` 上再叠一层 `fill-hover` | 再叠一层 `fill-selected` | — |
|
|
11
|
+
| 主按钮、实色危险按钮 | 不透明度 `opacity-solid-hover` | 同悬停 | — |
|
|
12
|
+
| 浅底危险按钮 | 在 `danger-soft` 上叠一层 `fill-hover` | 叠一层 `fill-selected` | — |
|
|
13
|
+
| 列表行、侧边栏项、菜单项、步骤行 | `fill-hover` 底 | `fill-selected` 底 | `fill-selected` 底,字重不变;选中项悬停时保持 `fill-selected`,不再叠加 |
|
|
14
|
+
| 可点的卡片 | 描边从 `line` 变 `line-control`,底色不变 | 同悬停 | 描边 `ink`,宽 `stroke-control` |
|
|
15
|
+
| 分段控件、标签页、折叠标题 | 文字从 `ink-secondary` 变 `ink`,不加底色 | — | 分段控件的选中块;标签页 `fill-selected` 底 |
|
|
16
|
+
| 本身是 `ink` 的文字链接(字标、页眉的产品名) | 变 `ink-secondary` | — | — |
|
|
17
|
+
| 正文里的链接 | 下划线从 `line-control` 变成文字颜色 | — | — |
|
|
18
|
+
| 输入框 | 不变 | — | 聚焦:边框 `focus-ring`,外圈 3px `accent-soft` |
|
|
19
|
+
| 玻璃按钮 | 比命中区小一圈的 `fill-hover` | `fill-selected` | `fill-selected`,图标 `ink` |
|
|
20
|
+
|
|
21
|
+
## 规则
|
|
22
|
+
|
|
23
|
+
- **悬停底色只用 `fill-hover`。** 它是半透明的黑(深色主题是白),叠在 `canvas`、`surface`、`surface-raised` 和玻璃上都成立。不要另选一个实色灰(比如 `#f2f2f2`),换一张纸面或换到深色主题就不对了。
|
|
24
|
+
- **三档,不叠加。** 悬停最轻,按下和选中一样深。选中项被悬停时保持选中的底色;按下时用 `fill-selected` 换掉 `fill-hover`,不是两层叠在一起。次按钮本身就是灰底,悬停和按下在它上面叠一层,这是唯一的叠加。
|
|
25
|
+
- **反馈只改颜色。** 悬停和按下只改底色、文字颜色或描边颜色(三者可以一起变,比如图标按钮底色和图标颜色一起变),不加阴影,不上浮,不缩放,不改字重和尺寸。
|
|
26
|
+
- **底色的形状和元素一样。** 悬停底色和元素同样大、同样的圆角(`row-radius`、`button-radius`、`menu-radius` 减内边距)。列表行在容器里左右缩进,不铺满整栏;命中区比可见尺寸大时,底色只画在可见范围里。
|
|
27
|
+
- **只在能悬停的设备上。** `:hover` 一律写在 `@media (hover: hover)` 里,触屏上不会停在悬停状态。触屏没有悬停,按下时(`:active`)立即给 `fill-selected` 反馈。
|
|
28
|
+
- **过渡要快。** 底色、文字颜色、描边在 `duration-instant` 内变化,进入和离开一样快,不加延迟。
|
|
29
|
+
- **焦点不靠悬停底色。** 键盘焦点用焦点环(`stroke-focus` 宽的 `focus-ring`,只在 `:focus-visible` 时出现)。列表和菜单里用方向键移动的高亮,用 `fill-hover` 加内描边焦点环,见 [菜单](../components/menu.md)。
|
|
30
|
+
- **禁用没有反馈。** 禁用的元素整体降到 `opacity-disabled`,没有悬停和按下的变化,光标恢复默认;不要隐藏,保持在原位。
|
|
31
|
+
- **光标。** 可以点的元素(按钮、行、标签、卡片、链接)用 `cursor: pointer`;文字输入用 `text`;可以拖动的用 `grab`,拖动中用 `grabbing`。
|
|
32
|
+
- **拖放。** 拖起来的元素用 `shadow-float` 浮起;可以放下的位置用 `fill-selected` 底,或者 `line-style-dropzone` 的虚线框。
|
|
33
|
+
|
|
34
|
+
## Web
|
|
35
|
+
|
|
36
|
+
下面的 `.dn-state` 是通用写法,适合行、文字按钮这类「透明底、悬停出底色」的元素。按钮、卡片、分段控件的写法见各自的组件说明。示例里的 `.is-hover`、`.is-active` 只用来在文档里把状态画出来。
|
|
37
|
+
|
|
38
|
+
```css
|
|
39
|
+
.dn-state { cursor: pointer; transition: background-color var(--duration-instant) var(--ease-standard), color var(--duration-instant) var(--ease-standard); }
|
|
40
|
+
@media (hover: hover) { .dn-state:hover { background-color: var(--fill-hover); color: var(--ink); } }
|
|
41
|
+
.dn-state:active, .dn-state.is-active { background-color: var(--fill-selected); }
|
|
42
|
+
.dn-state.is-hover { background-color: var(--fill-hover); color: var(--ink); }
|
|
43
|
+
.dn-state:is([aria-selected="true"], [aria-current="page"], [aria-current="true"], [aria-pressed="true"]) { background-color: var(--fill-selected); color: var(--ink); }
|
|
44
|
+
.dn-state:is(:disabled, [aria-disabled="true"]) { background-color: transparent; opacity: var(--opacity-disabled); cursor: default; }
|
|
45
|
+
.dn-state-row { display: flex; align-items: center; height: var(--row-height); padding: 0 var(--row-padding-x); border-radius: var(--row-radius); color: var(--ink-secondary); font: var(--weight-regular) var(--type-callout-size)/1 var(--font-sans); }
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
同一组状态放在 `canvas` 和卡片的 `surface` 上,半透明的底色在两种纸面上都成立:
|
|
49
|
+
|
|
50
|
+
```html
|
|
51
|
+
<div class="dn-row is-top">
|
|
52
|
+
<div class="dn-state-list">
|
|
53
|
+
<div class="dn-state dn-state-row">默认</div>
|
|
54
|
+
<div class="dn-state dn-state-row is-hover">悬停 · fill-hover</div>
|
|
55
|
+
<div class="dn-state dn-state-row is-active">按下 · fill-selected</div>
|
|
56
|
+
<div class="dn-state dn-state-row" aria-current="true">选中 · fill-selected</div>
|
|
57
|
+
<div class="dn-state dn-state-row" aria-disabled="true">禁用 · opacity-disabled</div>
|
|
58
|
+
</div>
|
|
59
|
+
<div class="dn-card dn-state-list">
|
|
60
|
+
<div class="dn-state dn-state-row">默认</div>
|
|
61
|
+
<div class="dn-state dn-state-row is-hover">悬停 · fill-hover</div>
|
|
62
|
+
<div class="dn-state dn-state-row is-active">按下 · fill-selected</div>
|
|
63
|
+
<div class="dn-state dn-state-row" aria-current="true">选中 · fill-selected</div>
|
|
64
|
+
<div class="dn-state dn-state-row" aria-disabled="true">禁用 · opacity-disabled</div>
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## SwiftUI
|
|
70
|
+
|
|
71
|
+
系统控件(`Button`、`List`、`Menu`、工具栏)自带悬停、按下和选中的效果,不要覆盖。自己画的可点区域:
|
|
72
|
+
|
|
73
|
+
```swift
|
|
74
|
+
struct RowButtonStyle: ButtonStyle {
|
|
75
|
+
func makeBody(configuration: Configuration) -> some View {
|
|
76
|
+
configuration.label
|
|
77
|
+
.frame(maxWidth: .infinity, minHeight: Deernova.rowHeight, alignment: .leading)
|
|
78
|
+
.padding(.horizontal, Deernova.rowPaddingX)
|
|
79
|
+
.background(configuration.isPressed ? Color.fillSelected : .clear,
|
|
80
|
+
in: .rect(cornerRadius: Deernova.rowRadius))
|
|
81
|
+
.contentShape(.rect)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
Button(action: open) { Label("周会记录", systemImage: "doc") }
|
|
86
|
+
.buttonStyle(RowButtonStyle())
|
|
87
|
+
.hoverEffect(.highlight) // iPadOS 接触控板时和系统一致
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
macOS 上自己画的行需要悬停底色时,用 `.onHover` 记下状态,再铺 `fillHover`;iPhone 没有悬停。
|
|
91
|
+
|
|
92
|
+
## 不要
|
|
93
|
+
|
|
94
|
+
- 用实色灰做悬停底色;悬停和选中用同一个底色,分不出当前在哪。
|
|
95
|
+
- 悬停时加阴影、上浮、放大、加粗,或者改变尺寸让布局跳动。
|
|
96
|
+
- 把 `:hover` 写在 `@media (hover: hover)` 外面,让触屏停在悬停状态。
|
|
97
|
+
- 选中项悬停时再叠一层变得更深;一个元素同时出现两种底色。
|
|
98
|
+
- 用悬停底色代替键盘焦点环;禁用的元素还有悬停效果。
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
- **字重**:粗体只出现在真正的标题上;分组标题、会话名、顶栏标题、说明是常规字重,靠灰度区分。
|
|
16
16
|
- **对齐**:单行控件在固定高度里垂直居中,没有用内边距、`top` 或等高行高去凑;图标和文字按中线,不同字号按基线;三行以上的内容图标对齐第一行;表格数字右对齐;空状态略高于正中;内容栏限宽居中,页面没有横向滚动,每一栏各自滚动。
|
|
17
17
|
- **间距与圆角**:间距在 4pt 网格上;组与组之间至少是组内的 2 倍;圆角来自组件的 `*-radius` token,嵌套圆角同心;阅读栏宽不超过 `content-max`。
|
|
18
|
-
-
|
|
19
|
-
-
|
|
18
|
+
- **交互**:一屏最多一个主按钮;按钮文字以动词开头;有悬停、按下、选中、禁用、加载、空、错误这些需要的状态,悬停底色是 `fill-hover`、选中和按下是 `fill-selected`,选中项悬停不加深,悬停没有阴影和位移,`:hover` 都在 `@media (hover: hover)` 里;禁用和加载时布局不跳动;键盘焦点清楚;菜单和下拉点击才打开,Esc、点外面、选中一项都能关闭,焦点回到触发器,方向键能走完所有项;弹窗打开时背景不能操作也不滚动,关闭后焦点回到原处,危险确认默认聚焦「取消」。
|
|
19
|
+
- **动效**:打开和关闭都有过程,退出比进入快一档,抽屉和底部弹窗用 `ease-drawer`,中途反向能平滑退回;时长来自 `duration-*`;动效说明因果;没有无目的的循环;减弱动态效果时只剩淡入淡出;切换内容时阅读位置不跳。
|
|
20
20
|
- **无障碍**:两套主题都看过;最大字号或 200% 缩放下不截断;只有图标的按钮有标签,指针端有 tooltip(不是 `title` 属性),键盘聚焦时也显示;降低透明度时玻璃变成实色。
|
|
21
21
|
- **文案**:用“你”;没有感叹号和 emoji;完成态用「已保存」;错误说明原因和解决办法;中文与英文、数字之间有半角空格。
|
|
22
22
|
|
|
@@ -59,7 +59,7 @@ Web 上按钮“上下特别宽”几乎都是同一个原因:用内边距和
|
|
|
59
59
|
- **内容区**:`canvas` 底;对话栏宽度不超过 `content-max`,在内容区居中;段落、工具调用行、卡片、任务清单之间 `space-4`。AI 回答用 `type-body`,不用衬线。
|
|
60
60
|
- **底部**:[Composer](../components/composer.md) 悬浮在内容之上,上面可以贴一条提示,下面是模型和权限的信息行;内容区底部留出它的高度。
|
|
61
61
|
- **侧面板**:右边可以并排打开终端、预览等面板,标签头和顶栏同高。
|
|
62
|
-
- 窄于 `breakpoint-md`(960
|
|
62
|
+
- 窄于 `breakpoint-md`(960)时侧面板变成[抽屉](../components/sheet.md);窄于 `breakpoint-sm`(760)时侧边栏也收起成抽屉,顶栏左侧放菜单按钮。不能出现横向滚动。
|
|
63
63
|
- 页面本身不滚动,侧边栏、内容区、侧面板各自滚动。其他页面类型(列表加详情、设置页、表格)的宽度,以及对齐规则,见 [布局与对齐](../foundations/layout.md)。
|
|
64
64
|
|
|
65
65
|
## 字体
|
|
@@ -72,9 +72,9 @@ Web 上按钮“上下特别宽”几乎都是同一个原因:用内边距和
|
|
|
72
72
|
- 焦点环:`outline: var(--stroke-focus) solid var(--focus-ring); outline-offset: var(--focus-offset);`,只在 `:focus-visible` 时显示。
|
|
73
73
|
- 细线:`border: var(--stroke-line) solid var(--line);`。
|
|
74
74
|
- 分段控件、开关这类有状态的控件,用 `aria-pressed`、`role="switch"` 或 `role="tab"` 标出当前状态。
|
|
75
|
-
- 弹窗用 `<dialog>` 的 `showModal()`,自带焦点管理和 Esc 关闭;popover 用 `popover` 属性。打开、关闭和焦点规则见 [
|
|
75
|
+
- 弹窗用 `<dialog>` 的 `showModal()`,自带焦点管理和 Esc 关闭;popover 用 `popover` 属性。打开、关闭和焦点规则见 [弹窗、抽屉与 popover](../components/sheet.md)。
|
|
76
76
|
- 菜单和下拉:选值优先用原生 `<select>`,命令菜单和链接列表按 [菜单与下拉](../components/menu.md) 选语义、处理键盘。
|
|
77
|
-
- 悬停效果放在 `@media (hover: hover)`
|
|
77
|
+
- 悬停效果放在 `@media (hover: hover)` 里,触屏上不会卡在悬停状态。各元素的悬停、按下、选中见 [交互状态](../foundations/states.md)。
|
|
78
78
|
|
|
79
79
|
## 验证
|
|
80
80
|
|
|
@@ -215,14 +215,17 @@
|
|
|
215
215
|
|
|
216
216
|
## 动效
|
|
217
217
|
|
|
218
|
-
|
|
218
|
+
动效时长和缓动。进入用 ease-enter,退出用 ease-exit 并降一档时长,从屏幕边缘滑入的面板用 ease-drawer。SwiftUI 里默认用 .smooth(等同 .spring(response: 0.35, dampingFraction: 0.85) 的手感)。
|
|
219
219
|
|
|
220
220
|
| Token | 值 | 用途 |
|
|
221
221
|
| --- | --- | --- |
|
|
222
|
-
| `duration-instant` | `120ms` |
|
|
223
|
-
| `duration-base` | `200ms` |
|
|
224
|
-
| `duration-slow` | `320ms` |
|
|
225
|
-
| `ease-standard` | `cubic-bezier(0.2, 0.8, 0.2, 1)` |
|
|
222
|
+
| `duration-instant` | `120ms` | 悬停、按压、选中这类即时反馈;tooltip 的进出;菜单、popover 的退出。 |
|
|
223
|
+
| `duration-base` | `200ms` | 菜单、popover、轻提示的进入;展开收起;弹窗和抽屉的退出。 |
|
|
224
|
+
| `duration-slow` | `320ms` | 弹窗、底部弹窗、侧边抽屉的进入,页面级转场。 |
|
|
225
|
+
| `ease-standard` | `cubic-bezier(0.2, 0.8, 0.2, 1)` | 状态变化和展开收起:颜色、高度、箭头旋转。快进、缓停。 |
|
|
226
|
+
| `ease-enter` | `cubic-bezier(0.16, 1, 0.3, 1)` | 出现:菜单、popover、弹窗、轻提示。起步快、停得很柔,前一半时间就完成大部分位移。 |
|
|
227
|
+
| `ease-exit` | `cubic-bezier(0.4, 0, 0.2, 1)` | 消失:配比进入低一档的时长,不拖泥带水。 |
|
|
228
|
+
| `ease-drawer` | `cubic-bezier(0.32, 0.72, 0, 1)` | 从屏幕边缘滑入滑出的面板:侧边抽屉、底部弹窗,进出都用它。 |
|
|
226
229
|
|
|
227
230
|
## 不透明度
|
|
228
231
|
|
|
@@ -254,6 +257,8 @@
|
|
|
254
257
|
| `breakpoint-sm` | `760px` | 窄屏:侧边栏收起,多栏变单栏。 |
|
|
255
258
|
| `breakpoint-md` | `960px` | 中等宽度:目录和次要栏收起。 |
|
|
256
259
|
| `breakpoint-lg` | `1280px` | 宽屏:内容不再继续变宽,两侧留白。 |
|
|
260
|
+
| `scrollbar-size` | `10px` | 网页滚动条占的宽度(横向滚动条是高度)。(仅网页) |
|
|
261
|
+
| `scrollbar-thumb-size` | `6px` | 滚动条里胶囊滑块的粗细,两边各留 (scrollbar-size − 它) ÷ 2。(仅网页) |
|
|
257
262
|
|
|
258
263
|
| Token | 桌面<br>Web 应用 · macOS 内容区 | 触控<br>iOS · iPadOS · Web 触屏 | 舒展<br>官网与文档 | 用途 |
|
|
259
264
|
| --- | --- | --- | --- | --- |
|
|
@@ -433,7 +438,7 @@
|
|
|
433
438
|
| --- | --- | --- |
|
|
434
439
|
| `menu-max-width` | `320px` | 菜单最大宽度;放不下的文字用省略号。 |
|
|
435
440
|
|
|
436
|
-
###
|
|
441
|
+
### 弹窗、抽屉与 popover
|
|
437
442
|
|
|
438
443
|
见 [components/sheet.md](components/sheet.md)。
|
|
439
444
|
|