Raft 设计系统

模式

列表

列表的行怎么画:没有分隔线;悬停、按下、菜单打开、选中和焦点都画在同一个内缩的圆角形状上;内容在这个形状里面留 12px 内边距;列表用自己的间隔让行与行相隔 2px;滚动容器让形状两边的距离保持一致。

列表的一行是一个单元格(cell)。这一行横跨整个列表的宽度,它的各种状态画在行内的一个圆角形状上:和行一样高,离两边各内缩 8px。列表用它的 gap 让行与行相隔 2px,上下各留 8px 内边距,和形状离两边一样远,所以两行都有填充时,中间露出 2px 的列表,不用线就把两行分开了:列表没有分隔线。行的内容以形状为基准留内边距,两端都在形状里面 12px,所以行里没有任何东西碰到它的形状。

适用范围

所有由行组成、每一行都是一个可以打开或选取的东西的列表:

  • 动态和通知:InboxItem。
  • 侧栏,以及手机上用侧栏行画的地点列表:Sidebar。
  • 搜索结果:Search(SearchEntityResult、SearchMessageResult)。
  • 手机上的地点列表和人的列表,以及进入其他页面的入口:带 cell 的 ListRow。

不适用于:

  • 表格:表格的行与行之间保留细线,因为它的列要横着读(Table)。
  • 设置列表:设置卡片里的行(由 plain ListRow 组成的 ListRowGroup)保留分隔线和整行宽的填充。一张设置卡片是一张从上读到下的表单,线把每一项设置和下一项连在一起;在卡片自己的圆角边框里再放一个内缩的圆角形状,只会白白多画出第三道边(设计评审,2026-10-26)。
  • 消息下方和侧边卡片里紧凑的任务行和文件行,以及任务页面的任务行,它们暂时保留自己的线(视觉舒适度;任务)。
  • 弹出菜单和选项列表,它们的行遵循间距。
  • 卡片列表(任务卡片、文件面板的卡片):每张卡片都是一个有自己边缘的对象,整张卡片都是点击目标,卡片之间的间隔保留。

结构

activity/compositions/activity-inbox
list-row/compositions/conversations

四种行组件共用一段配方片段 listCell(packages/ui/src/lib/list-cell.ts),所以它们不会彼此走样;装这些行的容器也共用同一个间隔和同一套内边距:

部分数值
行占满宽度,没有外边距,没有边框,透明。整行都是点击目标。它的高度不变:侧栏行高是 row-height,桌面上 34px(768px 起的触屏上 38px,手机上 44px)。
状态形状行的 ::before:和行一样高,离起始端和末端各 8px,radius-control(8px),在行的内容下面
两行之间的间隔2px,即列表的 gap(gap-0.5),从不用外边距,也不靠形状的内缩。间隔是 4px 时,两行对话之间的空隙显得太大(设计评审,2026-10-26)。
列表的两端上下各 8px 内边距(py-2),所以第一个形状在列表上边缘下方 8px,最后一个在下边缘上方 8px,和每个形状离两边的距离一样:圆角形状要从四边量,而不只是左右(设计评审,2026-10-26)。
容器SidebarList、SearchResultsList 和搜索里一个消息列的消息都是 flex flex-col gap-0.5 py-2;ActivityInboxViewport 是同样的列,它的下内边距再加上安全区域;DialogNav 也一样。其他任何单元格列表都是 flex flex-col gap-0.5 py-2 的列。
标题 → 第一行在每个列表里,从标题文字的底部到第一个形状都是 8px:标题(侧栏的分区标题或分组标签、搜索的分区标题、搜索里消息列的标题)没有下内边距,列表自己的 8px 就是全部的间距。分区为空时的那一行(SidebarSectionEmpty)同样在标题下方 8px。
内容以形状为基准留内边距,而不是以列表为基准:在形状里面,起始端和末端各 12px(--cell-pad-x),所以在手机和触屏上离列表边缘 20px(8px 内缩 + 12px);桌面上用鼠标时在形状里面 8px,所以离列表边缘 16px,也就是头部开始的页面内边距(stdrc,2026-11-05)。开头的头像、图标和复选框从形状的内侧起点开始;末尾的时间、计数、@ 贴纸、静音标记和悬停时出现的按钮(Mark as Done)结束在形状的内侧终点。上下保留行自己的内边距:InboxItem、搜索结果和 ListRow 是 12px(py-3),桌面上用鼠标时 InboxItem 和搜索结果是 8px,让动态和搜索与桌面的密度一致;触屏上 InboxItem、搜索结果和默认 ListRow 是 8px、最小高 56px;侧栏行是行高,用鼠标时 34px,768px 起的触屏上 38px,它让 20px 高的那一行文字居中(7px、9px)。起止两端(inline 方向)的内边距由 listCell 设置,所以行的配方不再自己设。

同一条边

列表所在的列上方有一个头部,常常还有一条工具栏(Filter、Mark all as read、搜索的筛选条件),以及分区标题。它们都从同一条线开始,也就是行的内容开始的那条线:在手机和触屏上离列的边缘 20px,桌面上用鼠标时 16px。行把内容放在 8px(形状)+ 12px 处,用鼠标时 8px + 8px;列的边距跟着行走。

部分离列的边缘
头部的标题,或头部开头的形状(工作空间按钮的头像、搜索框)20px(用鼠标时 16px)
工具栏的第一个控件按它的形状算(带描边的 Filter 按钮),最后一个按它的文字或图标框算20px(用鼠标时 16px)
分区标题、概要、分区为空时的那一行、列表里的横幅20px(用鼠标时 16px)
行的状态形状8px
行开头的头像、图块或复选框,以及末尾的时间、计数和按钮20px,在形状里面 12px(用鼠标时 16px,在形状里面 8px)

手机上其他的列保留 16px(对齐)。列的边距是 --column-margin 变量,每个头部、面板提示和底部区域都按它留内边距:手机上 16px,768px 起 20px;由列表单元格组成的列把它设成行内容的起点:手机和触屏上 20px,桌面上用鼠标时 16px(--page-inset)(ActivityInboxPanel、SidebarRoot、SearchShellRoot)。这种列里应用自己的工具栏或横幅按 px-(--column-margin) 留内边距;应用自己的单元格列在根元素上设 [--column-margin:20px] md:pointer-fine:[--column-margin:var(--page-inset)],和 raft-ui 自己的列一样;只设固定的 [--column-margin:20px] 会让用鼠标时头部在 20、行在 16。

为什么手机上不保留 16px:那样的话,要么行的内容只能放在形状里面 8px 处(有填充时内容就贴着形状,这正是本页要解决的问题),要么形状离屏幕边缘只有 4px,看起来像一条放错位置的通栏色带。整列用同一个边距,就能在任何宽度都保住 8px 的形状和它里面的 12px(设计评审,2026-10-27)。

列表行里的时间

说明某件事何时发生的行,把时间放在第一行(标题那一行)的末尾,别处都不放;它下面的行一直延伸到行的末端。在按天分组的列表里,只有今天的行,以及不到 1 小时的行,静止时显示时间。这些规则来自动态:那里的时间和任务编号占掉了行的大量宽度,把内容挤窄了(stdrc,2026-11-05,#proj-raft-ui:e09717fb:「时间不应该挤压内容」)。

  • 时间只放在第一行。 把时间和标记上下叠在一起的末尾列,在每一行上都和它最宽的部分一样宽,所以像 "2 hours ago" 这样的时间不光占了标题的宽度,也占了预览的宽度。放在第一行,时间只占标题的宽度;第二行保留整行的宽度,只减去它自己的标记。

  • 行的状态标记放在第二行的末尾:任务的状态标签、草稿标记、@ 贴纸、静音标记、未读数。第一行只放标题和时间。

  • 在按天分组的列表里,只有今天的行,以及不到 1 小时的行,静止时显示时间,用简短的相对时间,Space Mono、text-mono-meta、foreground-hint(字体排印)。在今天之内,人们读的是隔了多久(它等了多久),而分组标题已经写着 Today。完整的格式:

    距今显示
    不到 1 分钟,不论哪一天now
    1 到 59 分钟,不论哪一天3m,分钟数
    1 小时及以上,仍在今天2h,整小时数

    不写 "ago",数字和单位之间没有空格,所以一个时间是 2 到 3 个字符。中文写作 刚刚、3 分钟 和 2 小时:用中文自己最短的单位,并按中文的规则在数字和汉字之间留空格。

  • 不到 1 小时的行,即使在 Yesterday 下面也显示时间。 刚过午夜时,6 分钟前的一行会落到 Yesterday 下面,不显示时间的话,它看起来就和昨天早上的行一样旧;它显示 6 分钟,直到满 1 小时,之后才按更早的行的规则处理(Jasper,2026-11-05,#proj-raft-ui:#196)。

  • 更早的行只在悬停或键盘聚焦时显示时间。 今天之前、已满 1 小时的行静止时不显示时间:它的日期标题已经说明了是哪天。指针停在这一行上,或者这一行或其中的控件持有键盘焦点时,时间在同一个位置出现,没有动画:钟点时间,按这个人选的 12 小时制或 24 小时制(17:20、5:20 PM;写作)。标题给出了日期,所以钟点就够了。每个更早的行都显示时间,会把分组省下的宽度又占回去。

  • 在触屏上,更早的行的时间不在行上。 触屏上没有悬停,所以日期标题就是这一行的日期,这一行打开的对话会显示每条消息的时间。长按菜单里放这一行的操作,不放时间,就像消息的菜单也没有日期行一样(无障碍)。

  • 在行的无障碍名称里用文字说出时间,每一行都要,不管显示与否:"2 hours ago"、"yesterday at 17:20"、"Tuesday at 9:05"。"2h" 读出来只是一个字母,而隐藏起来的时间也必须让看不到标题的人知道。

  • 悬停操作出现时,让时间留在视野里。 操作占据行的末端,时间和标记向左移动,距离是操作的宽度加 8px;它们不淡出、不隐藏(stdrc,2026-11-05)。人们在清掉一行之前查看它有多久了,这时指针正停在这一行上。在 InboxItem 里,这适用于用 InboxItemLine 搭的行;仍在用 InboxItemTrailing 的行,在改用行布局之前,操作显示时仍让时间和标记淡出(InboxItem)。

  • 在触屏上,静止时就显示的操作和时间之间留 12px。 触屏上 Done 静止时就显示,是墨色描边的小按钮,时间和标记离它 12px,而不是 8px:按钮 44px 的点击区域超出它 32px 的盒子每边 6px,所以留 8px 的话,点击区域和时间的文字之间只剩 2px,而点时间的文字会打开这一行。12px 也是触屏上两个操作之间的间距(InboxItem;Jasper,2026-11-05,#proj-raft-ui:#196)。

按天分组的行

按时间排序、供人逐条处理的列表,比如动态(Raft Chat),按天给它的行分组,最新的在前,每一天有一个标题(stdrc,2026-11-05,#proj-raft-ui:e09717fb,方案 C1)。

  • 按对话里日期行的写法给日子命名:Today、Yesterday,然后是星期和日期,Tuesday, October 20(对话),更早的年份加上年份:Tuesday, October 20, 2025。按这个人自己的时区、按这一行显示的时间来算日子。
  • 把标题画成分区标题,而不是一行:text-footnote,字重 700,和所有分组标题一样(字体排印),从行的内容线开始,离列表边缘 20px(用鼠标时 16px)(同一条边),从它的文字到第一行的形状 8px(结构)。它没有悬停、按下,也不是点击目标。
  • 当前这一天的标题粘在列表顶部,它的行从它下面滚过,标题在列表自己的表面上,没有哪一行会透出来;下一天的标题到达顶部时把它往上推出去。让每一天自成一组,用一个容器包住它的标题和它的行,标题就在这个容器里粘住:粘性标题只停留在自己的组里,所以组的末端到达顶部时,标题跟着离开,下一天的标题接替它,而不是叠在它上面;所有标题都粘在同一个列表里时就会叠在一起。更早的行静止时不显示时间,所以它的标题是屏幕上唯一说明它是哪一天的东西;标题滚出视野,这些行就没有日期了。
  • 只给当前视图里有行的日子显示标题:在筛选下,一天的行都没了,就没有那一天的标题。
  • 把标题标记为列表标题下一级的标题(动态的 h2 下的 h3),让读屏软件可以一天一天地跳。

规则

  • 列表的两行之间不画线。 两行之间的 2px、开头的图块和头像,以及分区标题下方的空间,已经把行分开了。穿过列表的线还会切开列表上的每一个圆角形状。

  • 用分区标题和空间分开各组,从不用线。标题从行的内容开始的地方开始(离列表边缘 20px,用鼠标时 16px),而不是从形状的边缘开始。

  • 整行都是点击目标。 行的打开按钮盖住整行(InboxItemTrigger),或者这一行本身就是按钮,所以一行上的任何一点,直到它的边缘,点下去都落在这一行上。两行之间 2px 的间隔不属于任何一行:点在那里会落在列表上。这是用列表的 gap 而不是在行内部留间距的代价,而且它窄到没法瞄准。

  • 用列表的间隔隔开各行,两端各留 8px 内边距。 把行直接放进一个 gap-0.5 py-2 的列里。给行加外边距,或者让形状从行的上下内缩,都会让每一行各自画出自己那一份间距。虚拟化列表的行是逐个定位的,gap 管不到单独定位的行,所以在虚拟化列表里改为把这 2px 加到虚拟列表的项间距上。

  • 内容以形状为基准留内边距,从不以列表为基准。 形状内缩 8px,而内容仍然离列表边缘 16px,就只剩 8px;一旦应用把形状再往里缩,就一点都不剩,末尾的标记和 Mark as Done 按钮就压在形状的边上(设计评审,2026-10-27:「悬停时对勾图标和未读计数贴到了边上」)。在形状里面,内容两侧都保持 12px(桌面上用鼠标时 8px),起始端和末端一样,并且在每种状态下都一样:静止、悬停(显示出操作)、按下、选中、被定位和获得焦点。行里的任何东西都不能伸进这 12px,只有部件画在自己盒子外面的一圈例外:贴纸 1.5px 的外圈(计数、@ 贴纸)和形状之间还留着 10.5px。给部件一个和它的内容一样宽的槽位(20px 的选择复选框占一个 20px 的槽位)。文字过长时在内侧的内边距处截断。这个数值和网页版侧栏的行一致,也就是设计评审指定的参照:它的形状离侧栏两边 8px,末尾的计数在形状里面 11.5px(现在是 12px,起始端也一样,起始端原来是 9.5px)。

  • 头部、工具栏和标题从行的内容线开始。 在触屏上,任何宽度都离列的边缘 20px,用鼠标时 16px(同一条边)。在 20px 的行上方放一条 16px 的工具栏,会留下两条相差 4px 的边,看起来像是弄错了。

  • 不要从外面挪动形状或内容。 应用给滚动容器加侧边内边距(px-2)、挪动形状的末端(before:right-4)或设置行的内边距(px-2.5),都会破坏这条规则:形状不再离列表 8px,或者内容不再在形状里面 12px。

  • 所有状态都画在同一个形状上。 行保持透明;悬停、按下、菜单打开、选中、被定位的高亮和键盘焦点都是填充这个形状或在它上面画环,从不铺满行的整个矩形:

    状态形状上的填充
    悬停fill-cell-hover
    按下fill-cell-press,立即出现,不淡入
    菜单打开(右键菜单、长按)fill-cell-hover
    选中、在列表旁边打开(active 的 InboxItem、选中的 ListRow)、被定位fill-cell-selected
    搜索里的键盘高亮brand-soft
    侧栏里选中的行黄色贴纸:selected,它 1.5px 的墨色边在形状里面,贴纸边缘围在外面
    键盘焦点foreground 色的 2px 内嵌环,在形状里面(在侧栏选中的行上,在贴纸的墨色边里面)
  • 深色模式下加强行的状态,只加强行的。 浅色模式下,fill-cell-hover、fill-cell-press 和 fill-cell-selected 就是 fill-subtle、fill-medium 和 info-soft。深色模式下它们更强(7.5% 和 12% 的墨色,青色为 38%),因为在深色表面上,小圆角形状上 3% 的填充几乎看不出来。全局的档位不变:其他的悬停和按下状态仍然用它们(状态)。

  • 触屏上的按下和鼠标一样。 浏览器一把这一行标为按下(:active),手指的按下就在形状上显示 fill-cell-press,形状和颜色都和鼠标一样;只有悬停限定在能悬停的设备上。长按打开这一行的菜单后,菜单打开期间留下 fill-cell-hover。

  • 选中或禁用的行不响应悬停或按下。 它保留自己的填充。

  • 不管滚动条怎样,形状都离列表两边 8px:单元格列表的滚动容器用 ScrollArea。 它的覆盖式滚动条不占空间,所以在每个平台上,不论显示滚动条与否,形状都离滚动容器的外边缘 8px。ActivityInboxViewport、SearchShellViewport 和 Sidebar 的 SidebarContent 都是 ScrollArea;在手机上 Sidebar 原生滚动,那里平台的滚动条也是覆盖式的。滚动条显示时,它的滑块盖住行形状的最后 2px(指针在它上面、滑块变宽时是 5px):中号(medium)滑块停在离边缘 3px 处,宽 7px,被指向时 10px,所以它伸进列表 10px、被指向时 13px,而形状离边缘 8px。这是有意的:滑块只在滚动或被指向时显示,半透明,也不挪动任何东西,而且 Sidebar 的中号滑块本来就是这样放的;不要为了避开它给列表用小号(small)滑块(Jasper,2026-11-08,#proj-raft-ui:#209)。原生的经典滚动条出现时会从列表里占去自己的宽度,形状的末端也随之往里移这么多。没法做成 ScrollArea 的原生滚动容器,用 8px 的列表滚动条(scrollbar-list)和 useScrollbarSize 保住内缩:这个 hook 把滚动条占去的宽度发布为 --scrollbar-size,形状从末端的内缩里让出这么多。

  • 把单元格列表直接放进它的滚动容器,不加侧边内边距。 行自带内缩。滚动容器上的侧边内边距会叠加上去,还会在两侧各留下一条不属于任何一行的窄条。

  • 行里悬停才出现的操作用幽灵按钮(variant="ghost";设了 reveal="hover" 的 InboxItemActions),所有行列表都一样。它们出现在已经显示悬停填充的行上,在填充上再加一圈墨色描边,就是两行文字里又多一条边;不带边框,按钮读起来就是这一行的一部分(stdrc,2026-11-05,#proj-raft-ui:e09717fb:「done 可以换成 ghost 按钮」)。它的盒子和每个末尾部件一样,结束在形状里面 12px 处:它的内边距不像栏末端的无边框按钮那样向外悬出(视觉舒适),因为在行里那会伸进这 12px。

  • 悬停才出现的操作在行的高度上垂直居中,而不是对齐第一行:它们作用于整行(stdrc,2026-11-05:「hover 上去 done 按钮肯定要垂直居中」)。它们显示时,旁边的行给它们让出位置(列表行里的时间)。

  • 静止时就显示的操作仍用墨色描边的小按钮:在触屏上(reveal="hover" 在那里让它们静止时就显示),以及在等着这个人处理的行里,比如 NotificationCenter 里的一条通知。周围没有悬停填充时,静止的无边框图标读起来像一个标记,而不是按钮(Jasper,2026-11-05,#proj-raft-ui:b4d8d24f)。

宽屏和窄屏

形状、内容在形状里面的 12px,以及列的 20px 边距,在触屏上任何宽度都一样;桌面上用鼠标时,内容在形状里面 8px,边距是 16px(同一条边)。在手机上,侧栏行为了手指增高到 44px,形状也随之增高;两行之间的 2px 不变。在手机上,形状同样离屏幕两边都是 8px:滚动条盖在列表上,不占空间(规则)。

  • 行宽不到 360px 时,状态只用它的颜色标记表示。 状态标签保留它的像素点,用状态的颜色,去掉文字;文字放进像素点上的工具提示和这一行的无障碍名称里。在这个宽度下,文字("In progress")要占掉第二行大约三分之一,而第二行已经被它的标记缩短了(stdrc,2026-11-05,#proj-raft-ui:e09717fb)。按行的宽度判断,而不是窗口的宽度:动态的列在任何屏幕上都能窄到 320px(按区域的宽度切换)。工具提示在触屏上不会打开;在那里,文字仍在这一行的名称里,也在这一行打开的内容里。