Raft 设计系统

基础

状态

悬停、按下、焦点、选中、未读、禁用、错误、忙碌和自动填充的样子,所有组件通用。

所有组件用同一套方式表现交互状态。组件文档只在某个状态与本页不同时才单独说明。

原则

  • 悬停和按下是加一层叠加层,分两档。 它们从不改变填充色,见 动效。供人阅读的大块内容悬停用浅档,小控件悬停用中档,所有可点击的东西按下都用中档(悬停与按下)。选中的项不响应悬停,输入字段不做悬停。可点击的文字没有可以叠加的底框,所以悬停时改为加下划线(可点击文字)。
  • 选中有三种含义,对应三种样子。 导航中的当前位置是黄色,内容中被单独标出的项是青色,选定的值是墨色。
  • 禁用在哪里都一个样,也不响应悬停和按下。

用法

状态样子
悬停、键盘高亮菜单、选择列表和菜单里的快捷回应填充 brand-soft;搜索结果的键盘高亮也一样。行、消息和供阅读的卡片叠加浅档 fill-subtle;按钮、标签片、标签页和分段项叠加中档(悬停与按下)。工具栏里的操作是按钮,不是菜单行,即使工具栏像菜单一样浮起(选中文字上方的那个)也是如此:它们悬停时用 fill-medium 变灰,从不用 brand-soft
按下一律用中档,行和卡片也一样:fill-medium,彩色贴纸上用 sticker-medium,外框上用 fill-frame-medium(颜色)。文字按钮按下时点亮它的点击区域
焦点见 焦点
选中:导航一张黄色贴纸:侧边栏的行、分段控件、标签页、标签栏的图标。浅色模式下在黄色外框上,导航条的选中项是白色贴纸;深色模式下是黄色
选中:内容info-soft:当前打开的项、跳转到的那条消息、生效中的筛选条件
选中:值墨色:勾选的复选框或单选框。在菜单里,当前值带一个对勾,文字加粗
未读文字加粗,加一个粉色计数。有人提及这个人时,用黄色的 @ 标记代替计数。在图标上,是一个粉色像素点。选中的项两者都不显示
禁用填充 fill-muted,边框 line-hairline 或 foreground-disabled,文字 foreground-disabled。按钮、带边框的图标按钮、输入框、选择框、复选框、单选框、开关、分段控件和标签页都一样。无边框的图标按钮和外框上的无边框按钮不加填充也不加边框:只把图标和文字变成 foreground-disabled。列表行保留填充:只把文字和图标变成 foreground-disabled。不响应悬停和按下
仅展示一行里只有控件可以交互(设置里带开关的行、侧边栏里的这台计算机)时,这一行不是按钮:渲染为 <div>,行本身没有悬停和按下,焦点只落在控件上
错误输入框加 danger-strong 边框,并加一行文字说明怎么改。在对话框里用横幅。整个服务器的问题放进通知中心,见 反馈
忙碌按钮里放一个像素加载指示器,标签写明正在做什么。其他地方,加载指示器与它所替代的东西匹配:在一行文字里,保持这一行的高度;替代面板或列表的内容时,用 md 或 lg,配一行 text-callout 或 text-body、foreground-muted 的文字(Spinner)

悬停与按下

悬停表示「指针在这里,点击会作用于这个东西」;按下确认这次点击。两者都是在静止颜色上叠一层半透明的墨色,从不换成另一种填充色,所以在白色、偏黄的侧边栏和卡片上,这一步变化看起来都一样。叠加分两档,按指针下的东西来定,而不是按组件碰巧用了哪个 token 来定:以前 raft-ui 有九种把东西变灰的方式,变化幅度从 2.1 到 8.4(OKLab ΔE × 100),两张看起来一样的卡片悬停时分别是 2.7 和 5.7。

档位用于Token浅色深色变化幅度
浅档供人阅读的大块内容的悬停:行、消息、供阅读的卡片(评论、附件、回复条、任务卡片),以及菜单已打开的行fill-subtle(overlay-subtle)4% 墨色3% 纸面色2.5 到 3
中档小控件的悬停:按钮、图标按钮、标签片、徽标、标签页、分段项、导航项。所有可点击东西的按下fill-medium(overlay-medium)7.8% 墨色6.5% 纸面色5 到 6.5
彩色贴纸上的中档主按钮、警告按钮和危险按钮,彩色徽标,按下选中的分段项sticker-medium(overlay-sticker-medium)10% 墨色10% 墨色5.5 到 6
外框上的中档导航条项、外框上的按钮、工作空间按钮、通知入口fill-frame-medium(overlay-frame-medium、bg-fill-frame-medium)10% 墨色6% 纸面色约 6.4
黄色高亮弹出层中的当前项,指针和键盘都适用:菜单项、选项、建议、快捷回应brand-soft不透明20% 黄色8.1 / 5.9
无选中的项:选中的分段项或切换按钮、侧边栏的当前项、选中的标签页、选中的行。输入字段:输入框、选择框的触发器、搜索框————
  • 供阅读的内容用浅档。 一条消息可能有二十行高,一行横跨整个面板;人在阅读时指针就停在上面。面积大,小的变化也看得见;正文在上面最多损失 6 APCA Lc,foreground-hint 在每种表面上都保持 4.5:1 以上(对比度)。以前消息的悬停比行浅(3% 和 2.2%),供阅读的卡片更重(7.8%);现在同一屏上的行、消息和卡片变化幅度一致。
  • 小控件用中档。 一个 32px 的按钮需要在指针下给出明确的反馈,也没有人会透过它阅读。透明度按底色区分,让变化看起来一样:同样的墨色在深色、黄色和粉色上更显眼,所以深色模式用 6.5%,贴纸和黄色外框用 10%,深色外框用 6%。
  • 所有可点击的东西按下都用中档。 按下只持续一瞬,用来确认点击,所以可以重一些:行、卡片、标签片、徽标、提及和选中的贴纸(选中的分段项、切换按钮、标签页或侧边栏项叠加 sticker-medium)。一个控件的悬停和按下用同一档:没有第三档更重的按下。消息没有按下:这一行本身不是操作(点击是选中文字,或落在其中的链接、名字或按钮上),所以它的悬停只标出工具栏和菜单作用于哪条消息。
  • 菜单打开的行用浅档,不论是否悬停。 任何行,无论是消息还是别的,在它打开的菜单或弹出层还在时都用 fill-subtle(行上或行内按钮上有 data-popup-open);选中的行保留选中样式。手机上没有悬停,这是菜单作用于哪一行的标志(在每种行表面上变化 2.5 到 2.9)。用鼠标时,这就是它原本的悬停,两者不会叠加。
  • 低于选中。 两档灰色在同一表面上都低于选中色,所以悬停永远不会被看成选中:在纸面上,info-soft 是 6.4(浅色)和 4.6(深色),brand-soft 是 8.1 和 6.1。悬停保持中性的墨色;选中保留它的颜色(选中:内容)。
  • 选中的项不响应悬停。 选中是这个项上最强的信号;悬停时再加深并不增加信息,反而让它看起来像别的东西。选中的分段项或切换按钮,和选中的标签页、侧边栏当前项一样,在指针下保持原样;按下时叠加中档。
  • 黄色高亮是弹出层的光标,不是一种灰。 它跟随指针,也跟随键盘。深色模式下是 20% 黄色:16% 时只有 5.0,比弹出层旁边按钮的悬停还弱。
  • 输入字段不做悬停。 输入字段的状态是它的光标、焦点环和错误;悬停会对一个用来输入的东西说「点这里执行操作」。选择框的触发器和顶栏的搜索框画成输入字段的样子,所以也不做悬停;按下时仍叠加中档。
  • 只有一个信号。 悬停从不在表面上再加第二种变化:除了叠加层,不改边框、阴影或填充。一个例外:无边框控件的图标或标签静止时是 foreground-muted 或 foreground-hint(输入字段的清除按钮和下拉箭头按钮、系统消息的展开按钮、图标按钮),悬停时这些内容随叠加层一起变成 foreground,就像可点击文字变成墨色一样;是内容在回应,表面并没有变化两次。
  • 只在指针能悬停时生效。 所有悬停都放在 @media (hover: hover) 下:Tailwind 的 hover: 已经这样做,CSS 文件里的 :hover 规则也包在同一个查询里。在触屏上,悬停会一直停留在最后点过的东西上。

自身有填充的东西,用叠加层工具类(overlay-subtle、overlay-medium、overlay-sticker-medium、overlay-frame-medium);透明的控件、填充用 transition-colors 渐显的,用颜色类(bg-fill-subtle、bg-fill-medium、bg-fill-frame-medium)。

可点击文字

能执行操作或跳转、周围没有底框、标签片或填充的文字:TextLink、对其他人的提及、发送者的名字、消息下方的文字操作、Toast 里的操作。

  • 静止时不加下划线,悬停时用文字本身的颜色加下划线:下划线是所有可点击文字唯一的悬停信号,人只需学一次。静止时,靠它的颜色、字重和所在位置说明它可以点。
  • 可点击文字悬停时不要变粉。粉色是屏幕上唯一的主操作(颜色);悬停时的颜色变化只能是从弱化色变成正文的墨色。
  • 不能点击的文字悬停时不做任何变化;可点击文字禁用时去掉悬停下划线,让悬停时的变化始终表示「这可以点」。
  • 例外:渲染出来的消息或文档正文里的链接(Markdown)静止时保留下划线,因为单靠链接蓝色与周围正文太接近:低于 WCAG 1.4.1 对仅靠颜色区分的链接要求的 3:1。下划线静止时较淡,悬停时变成完整颜色;具体数值见 Markdown。
  • 标签片、卡片、徽标、按钮和标签页不是可点击文字:它们保留自己的悬停档位或自己的悬停样式。

焦点

焦点用 focus-ring(层级),并且只在这个人最近一次导航输入是键盘时显示。焦点环回答的是用 Tab 和方向键移动的人「我在哪」。用点击或轻点的人已经知道自己在哪,因为刚刚指过去,点过的文本框也有光标标出。每次点击都出现、还一直停在常驻焦点的输入框上的焦点环,只会分散注意力,没有提供任何信息。

  • 键盘导航后显示焦点环:任何地方的 Tab、Shift+Tab 和 F6;文本框之外的方向键、Home、End、Page Up、Page Down、Enter、Space 和 Esc。用 Tab 进入文本框时显示完整的焦点环。
  • 在文本框里这些键用来编辑,所以保持原有的输入方式不变,除非焦点随即离开文本框:Esc 关闭文本框所在的对话框、Enter 提交、左方向键从组合框的文字移到它的标签片上。这时这个人正在用键盘,焦点落到的元素显示焦点环。
  • 点击或轻点后不显示焦点环,代码在点击之后移动焦点时也不显示(对话框聚焦第一个输入框、弹出层聚焦它的搜索行)。用鼠标点击的文本框只显示光标:没有焦点环,边框也不变。
  • 打字不算导航。字母,以及文本框里的 Enter、Space 和方向键,都保持原有的输入方式,所以点击后在输入框里打字时输入框保持安静,用 Tab 进入时则保留焦点环。
  • Cmd 和 Ctrl 快捷键,以及单独按下的修饰键,也不算。快捷键把焦点移到没有光标的地方时,如果没有别的原因能解释这次移动,仍然显示焦点环(见下一条);快捷键把焦点送到文本框(Cmd+K)时显示光标。
  • 输入不是我们能看到的指针时,所有焦点环都显示:在任何输入之前(页面刚加载;键盘用户还没按 Tab),焦点到达前没有按键或按下时(读屏软件的浏览光标、定时器),以及读屏软件合成一次按下时(VoiceOver、TalkBack)。只有真实的指针按下,或在其他输入之前的真实鼠标移动,才会隐藏焦点环。键盘和读屏软件用户永远不会失去它(WCAG 2.4.7)。
  • 复合控件给整组加焦点环:输入框(composer)、带前缀或后缀的输入框、多选字段。只有组内某个元素获得键盘焦点时(has-focus-visible)整组才显示焦点环,组内有任意焦点(focus-within)不算:点击搜索框的清除按钮不会让搜索框显示焦点环。
  • 复选框或单选框给方框加焦点环。由一个隐藏的原生 input 驱动。

raft-ui 用 installInputModality() 实现这条规则。ThemeProvider 会安装它,所以用 ThemeProvider 的应用不需要自己写代码。它在 document.documentElement 上维护 data-input-modality:没有依据之前不设置,之后是 pointer、keyboard 或 virtual。只有值为 pointer 时,styles.css 才会清空 focus-ring,浅色和深色模式、浅色或深色局部区域都一样;其他任何值,以及没有值时,都显示焦点环。

  • 用 focus-visible:shadow-focus 画焦点指示(组上用 has-focus-visible:shadow-focus),不要用 focus: 或 focus-within:。
  • 不是用 focus-ring 画的指示,比如 outline 或内嵌的环,叠加 keyboard: 变体:focus-visible:keyboard:outline-2 focus-visible:keyboard:outline-line-strong。除非最近一次输入是指针,否则它都匹配。

触屏上的拖动

在触屏上,对一个可以移动的项,一次长按可能有两种意思:打开它的菜单,或把它拿起来。按住不动是菜单;按住再移动是拖动。iOS 就是这样区分两者的,这样菜单和拖动永远不会争抢同一时刻。

  • 手指按住不动 500ms 后进入待拖动状态,这正是 raft-ui 的长按菜单(ContextMenuTrigger)打开、iOS 长按触发的时刻,所以菜单和待拖动的项同时出现。Android 自己的长按在 400ms 触发:它的菜单先出现,一直保留到拖动开始。
  • 之后手指第一次移动 10px 时开始拖动。
  • 在 500ms 到之前手指移动超过 10px(那是滚动),或第二根手指按下(那是捏合),就放开这个项。
  • 按住后不移动就抬起手指,菜单保持打开,什么都不移动。
  • 拖动开始时关闭这个项的菜单。由拥有菜单的组件关闭它:应用在可拖动的项上放了自己的菜单,就在拖动的 onDragStart 里关闭(SortableTabsList 会把它传下去)。
  • 所有能用触屏拖动的东西(侧边栏的行、标签页、任务卡片、输入框里的附件)用同样的按住时长、距离和容差:raft-ui 的 useHoldThenMoveTouchSensor 承载这些值,旁边还有一个把手指交给它处理的指针传感器。鼠标或触控笔仍然在移动 5–6px 后开始拖动,不需要按住。
  • 不要让按住的项选中文字或打开平台自带的标注菜单(select-none、-webkit-touch-callout: none),并保持它可以滚动(touch-manipulation,不是 touch-none)。

自动填充

浏览器自动填入的字段(保存的邮箱或密码)看起来和其他字段一样:layer-card 配墨色文字。

  • 不要显示浏览器的自动填充底色:Chrome 的浅蓝和 Safari 的黄色让被填充的字段成为页面上唯一与系统其他部分都不搭的字段,而这个颜色也没有告诉人任何需要知道的东西。
  • raft-ui 的 styles.css 在 Chrome 和 Safari 中对页面上所有 input、textarea 和 select 做了这件事,所以应用不需要自己写代码。禁用的自动填充字段保持禁用的墨色。
  • 颜色:这里提到的 token。
  • 层级:焦点环。
  • 反馈:错误按范围放在哪里。