IvyLabs Design BostonIvy 爬山虎 v0.3.0

多语言与从右往左

爬山虎的界面和用户写的内容可能是任何语言。这一章规定:每种文字用什么字体、行高和字距怎么随文字变、阿拉伯文这类从右往左的语言怎么镜像,以及数字、日期和长文本的写法。规则都落在 token 里,Web 和 Flutter 读同一份数据。

支持的语言

语言 标签 文字系统组 方向 备注
英语 en 默认 从左往右
简体中文 zh-CN hans 从左往右 不带地区的 zh 也归这一组
繁体中文 zh-TW hant 从左往右 香港 zh-HK 单独一组 hant-hk
日语 ja jpan 从左往右
韩语 ko kore 从左往右
西班牙语、法语、德语、意大利语、葡萄牙语(巴西)、印尼语 es fr de it pt-BR id 默认 从左往右 品牌字体全部覆盖
俄语、土耳其语 ru tr 默认 从左往右 萌虎、像素的部分字体缺字母,整段换字体,见下文
越南语 vi viet 从左往右 拉丁字母,但大写上叠两层符号,行高放宽
印地语 hi deva 从左往右
孟加拉语 bn beng 从左往右
泰语 th thai 从左往右
阿拉伯语 ar arab 从右往左 波斯语、乌尔都语也归这一组

先告诉界面用的是哪种语言

字体、行高、字距和镜像全部跟着语言标签走,所以第一步是把语言写对。

Web:在 <html> 上写 lang 和 dir,用 @bostonivy/design 的 applyLocale:

import { applyLocale } from '@bostonivy/design';

applyLocale(document.documentElement, 'ar'); // <html lang="ar" dir="rtl">

Flutter:MaterialApp 配好 locale、supportedLocales 和 GlobalMaterialLocalizations 等代理,Directionality 会跟着语言自动变成从右往左。主题要把同一个 locale 传进去:

MaterialApp(
  locale: locale,
  supportedLocales: supportedLocales,
  localizationsDelegates: GlobalMaterialLocalizations.delegates,
  theme: bostonIvyTheme(BiStyle.clean, Brightness.light, locale: locale),
  darkTheme: bostonIvyTheme(BiStyle.clean, Brightness.dark, locale: locale),
);

跟随系统语言时,用 WidgetsBinding.instance.platformDispatcher.locale,并在 didChangeLocales 里重建主题。

字体回退

品牌字体(Inter、Fredoka、JetBrains Mono、Silkscreen)只负责拉丁字母。其他文字交给系统自带的字体,客户端不打包:一套中日韩字体就有 10 到 20 MB,而且系统字体和用户在别的应用里看到的一致。

每个字体栈都按这个顺序拼起来:

  1. 风格的品牌字体
  2. 界面语言那一组的系统字体
  3. 其余各组的系统字体(一段文字里混着几种文字时,每种文字都有固定的字体)
  4. 彩色 emoji
  5. system-ui,然后是通用字族(sans-serif 或 monospace)

第 5 步的 system-ui 不能省。实测 macOS 上的 Chrome:品牌字体是等宽的 JetBrains Mono 时,如果没写明阿拉伯文字体,浏览器会挑一个等宽的阿拉伯文字体来回退,字母一个个断开。所以阿拉伯文组里写了 macOS 自带的 Geeza Pro。

组 语言 macOS / iOS Windows Android / Linux
hans 简体中文 苹方-简、冬青黑体简体中文 微软雅黑 思源黑体 SC(Noto Sans CJK SC)
hant 繁体中文(台湾) 苹方-繁 微軟正黑體 思源黑體 TC
hant-hk 繁体中文(香港) 苹方-港 微軟正黑體 思源黑體 HK
jpan 日语 ヒラギノ角ゴシック Yu Gothic UI、Meiryo UI Noto Sans CJK JP
kore 韩语 Apple SD Gothic Neo 맑은 고딕(Malgun Gothic) Noto Sans CJK KR
deva 印地语等 Kohinoor Devanagari Nirmala UI Noto Sans Devanagari
beng 孟加拉语等 Kohinoor Bangla Nirmala UI Noto Sans Bengali
thai 泰语 Thonburi Leelawadee UI Noto Sans Thai
arab 阿拉伯语等 Geeza Pro Segoe UI Noto Sans Arabic UI、Noto Naskh Arabic UI

完整的字体名单在 bostonivy-tokens.json 的 core.script.<组>.font,emoji 在 core.font.emoji。

为什么中日韩要分组:同一个汉字码位在简体、繁体、日文、韩文里字形不同,比如「骨」「直」「說」。浏览器和 Flutter 都靠语言标签挑字形,语言标签不对,日文界面里就会出现中文字形。

三种风格怎么保持一致

风格 拉丁字母 其他文字 风格靠什么撑住
青藤 Inter 系统黑体 Inter 和各语言的系统黑体气质接近,不需要特殊处理
萌虎 Fredoka 先找圆体(圆体-简、圆体-繁、ヒラギノ丸ゴ),没有就用系统黑体 粗描边、硬阴影、糖果色;Windows、Android 和非中日文没有圆体,字形是普通黑体
像素 JetBrains Mono,标题和计时器用 Silkscreen 系统黑体,不用点阵字体 直角、1px 描边、[ ] !!! #标签 这些写法、像素吉祥物

行高与字距

天城文、孟加拉文、泰文、阿拉伯文的符号会伸到拉丁字母上下方很远的地方,越南文的大写字母上叠两层符号。行高不够时,多行文字会互相碰到;单行省略的元素(overflow: hidden)还会直接把字形切掉。每组都有一个行高下限,三种风格的行高只会被抬到这个下限,不会被压低。

组 行高下限(字号的倍数) 字距 依据
默认(拉丁、西里尔、希腊) 不限 保留
hans hant hant-hk jpan kore 不限 归零 方块字本身排得很满,现有行高按中文调过
viet 1.45 保留 Inter 的「Ẫ」顶部到 1.09 em
deva 1.45 归零 元音符号、连字上下伸出;字母靠顶线连在一起,加减字距都会断开
beng 1.45 归零 同天城文
thai 1.6 归零 声调符号叠在元音上,Thonburi 顶部到 1.15 em
arab 1.75 归零 连写体,字距会断开连笔;Geeza Pro 下伸 0.51 em

依据一栏的数值是用 HarfBuzz 排版常见界面文字、量出来的墨迹范围。下限的算法:品牌字体的行盒在基线上下留出的空间,要能装下墨迹的最高点和最低点。

从右往左

阿拉伯语、波斯语、乌尔都语、希伯来语界面整体镜像:阅读从右边开始,界面也从右边开始。

布局

一律写逻辑方向,不写左右:

不要写 要写(Web) 要写(Flutter)
margin-left、padding-right margin-inline-start、padding-inline-end EdgeInsetsDirectional
border-left border-inline-start BorderDirectional
left: 0 inset-inline-start: 0 PositionedDirectional
text-align: left text-align: start TextAlign.start
Alignment.centerLeft — AlignmentDirectional.centerStart

跟着镜像的:侧栏移到右边,详情面板移到左边;勾选框在行首,也就是右边;看板的列从右往左排,甘特图的时间从右往左走;进度条从右往左填充;返回箭头指向右边;头像叠放的方向反过来;Android 上左滑、右滑对应的操作对调。在树和横向列表里,键盘的 ← → 键的含义也随方向对调,按 WAI-ARIA 的写法实现。

不镜像的:时钟和计时器圆环(永远顺时针);播放、停止这类媒体控件;数字本身(永远从左往右写);代码、快捷键、URL;Logo、吉祥物和插画。

图标

35 个图标里,有 7 个指向阅读方向,或者画的是从起始边排起的文字和布局,从右往左时要水平翻转:

翻转 原因
chevron-right 指向「下一个」「展开」
comment 气泡的尾巴在起始边
label 标签的尖头指向行尾
relation 带方向的折返箭头
view-list、view-gantt、view-kanban 画的是从起始边排起的列表、时间轴和看板列

其余图标不翻转,包括 timer(时钟)、play stop(媒体)、repeat(循环)、check checklist(勾)、search、attachment、priority(旗子是实物)。

三种风格的装饰

风格 元素 从右往左时
青藤 投影 投影是垂直方向的,不用处理
萌虎 右下方的硬阴影;按下时向阴影方向移动 3px 不镜像。阴影表示光从左上方照过来,光源是实物方向,不跟阅读方向走
萌虎 勾选时旋转 −6°、拖动卡片倾斜 3°、侧栏吉祥物歪头 不镜像,都是装饰
像素 选中行起始边的 3px 竖条 镜像,移到右边。Web 用 --inline-sign(从右往左时为 −1),Flutter 用 BorderDirectional(start: …)
像素 [ ] [x] 勾选框 当作文字放在从右往左的文本里,方括号会按 Unicode 规则自动镜像显示。不要再包一层 dir="ltr"
像素 !!!、#标签、@林 跟着文字方向走,# 和 @ 自然出现在词的右边
像素 空状态里的命令 $ bivy ls --today 这是代码,永远从左往右。Web 包在 <code dir="ltr"> 里,Flutter 用 Directionality(textDirection: TextDirection.ltr)
像素 快捷键提示条 条目顺序镜像,键帽上的字母不变

双向混排

从右往左的文本里夹着数字和拉丁字母时,「#42」「00:42:18」「v0.3.0」「report.pdf」这类片段会被前后文字拉到错误的位置。规则是把它们隔离开:

用户输入的任务标题和评论:Web 用 dir="auto";Flutter 用 intl 的 Bidi.detectRtlDirectionality 判断方向,再传给 TextField 和 Text 的 textDirection。

数字与日期

一律交给平台的本地化接口,不要自己拼字符串:Web 用 Intl.DateTimeFormat、Intl.NumberFormat、Intl.RelativeTimeFormat、Intl.PluralRules,Flutter 用 intl 包的 DateFormat、NumberFormat 和 MaterialLocalizations。

规则 写法
数字字形 跟随语言的默认写法:ar-EG、ar-SA 用阿拉伯–印度数字 ٠١٢,fa 用 ۰۱۲,bn 用 ০১২,其余用 0–9
例外:编号和代码 任务编号 #42、版本号、快捷键、URL、ISO 日期永远用 0–9(numberingSystem: 'latn'),并按上一节隔离方向
等宽数字 计时器、表格里的数字列、计数一律等宽:Web 用 font-variant-numeric: tabular-nums,Flutter 用 FontFeature.tabularFigures()。Fredoka 不带等宽数字,萌虎的计时器容器要按「00:00:00」预留宽度,数字变化时才不会跳
历法 截止日期一律用公历(calendar: 'gregory')。th-TH 默认佛历(2569 年),fa 默认波斯历,ar-SA 可能用伊斯兰历;同一个截止日对团队里的每个人都要是同一天
时区 存 UTC,按用户时区显示;全天任务只存日期
12 / 24 小时制 跟随语言和系统设置(hourCycle):英语、韩语、阿拉伯语、印地语默认 12 小时制,中文(大陆)、日语、德语、俄语默认 24 小时制
一周的第一天 跟随地区:美国、日本是周日,中国大陆和欧洲大部分是周一,中东是周六(Intl.Locale 的 getWeekInfo(),Flutter 的 MaterialLocalizations.firstDayOfWeekIndex)
相对时间 「明天」「3 天前」用 RelativeTimeFormat,numeric: 'auto'
复数 用 ICU MessageFormat。俄语有 4 种复数形式,阿拉伯语有 6 种;不要写成「数字 + 天」
千分位和小数点 跟随语言:1,234,567.5(英、中、日)、1.234.567,5(德、西)、1 234 567,5(法、俄)、12,34,567.5(印地语)

同一时间点(10月20日 18:00)在列表里的短格式,由 Intl.DateTimeFormat 生成:

语言 短格式 语言 短格式
en Oct 20, 6:00 PM de 20. Okt., 18:00
zh-CN 10月20日 18:00 ru 20 окт., 18:00
zh-TW 10月20日 下午6:00 ar-EG ٢٠ أكتوبر، ٦:٠٠ م
ja 10月20日 18:00 hi 20 अक्टू॰, 6:00 pm
ko 10월 20일 오후 6:00 th 20 ต.ค. 18:00

文本长度

按英文设计,给翻译留余量:德语、俄语、法语通常比英文长 30%,两三个词的短标签可能长一倍;中日韩短 30% 到 50%,但字更高。

元素 放不下时
按钮 不截断。按钮随文字变宽;一行放不下时,按钮组改成纵向排列。只有图标的按钮要有 aria-label
标签、胶囊 最宽 160px,单行省略;全名放进 tooltip 和无障碍名称
导航项、项目名 单行省略,加 tooltip
任务标题 列表里单行省略,详情里完整换行
页面和分组标题 换行,不截断
表头 最多两行,不截断
计数徽章 超过 99 写「99+」

断行规则:

测试方法:每个界面至少用德语、阿拉伯语、日语各过一遍。Android 打开开发者选项里的伪语言 en-XA(文字拉长并加上重音符号)和 ar-XB(强制从右往左);Web 用脚本把文案拉长 40% 再看一遍。

Token 与变量一览

用途 token CSS 变量 Flutter
各组系统字体 core.script.<组>.font --font-script(界面语言那一组在前)、--font-scripts(全部) BiScript.<组>.fonts、BiFonts.scripts
萌虎圆体 core.script.<组>.font-rounded --font-script-rounded BiScript.roundedFonts
emoji core.font.emoji 包含在 --font-scripts 里 BiFonts.emoji
品牌字体替换 style.*.font 的 substitute / substituteFor --font-sticker-face、--font-pixel-display-face BiFonts.family(style, role, locale)
完整字体栈 — --font-clean、--font-sticker、--font-pixel、--font-pixel-display、--font-mono(经 --font-ui 等别名使用) BiFonts.fallback(style, role, locale)
行高下限 core.script.<组>.leading-min --leading-min BiScript.leadingMin
字距倍数 core.script.<组>.tracking-scale --tracking-scale BiScript.trackingScale
语言 → 组 core.script.<组> 的 languages :lang() 选择器 BiScript.of(locale)
镜像图标 rtl.mirrorIcons .bi-mirror-rtl、mirroredIcons BiIcons.mirrorInRtl
行内方向符号 — --inline-sign(1 或 −1) Directionality.of(context)