Friend 品牌 UI 不变量 INV-UI-1 全解:禁止紫色与白/中性色强调体系(No-Purple 无增长棘轮)
【免费下载链接】FriendAI that sees your screen, listens to your conversations and tells you what to do项目地址: https://gitcode.com/GitHub_Trending/fr/Friend
导读
本文围绕 Friend 开源仓库的product/invariants/brand-ui.md这一条锁定(locked)产品不变量INV-UI-1展开,系统讲解该项目的品牌视觉红线:界面中一律不使用紫色,主操作与强调色统一采用白色/中性色处理。你将掌握这条不变量在桌面端 SwiftUI、Flutter App 与 Web 端的具体落地范围,以及仓库如何用.github/scripts/check_brand_ui.py的**无增长棘轮(no-increase ratchet)**在 CI 中自动拦截新增紫色,包括其检测模式、HSV 色相判定算法、允许列表与 PR 协作规则。
一、不变量是什么:品牌红线的定义与状态
在 brand-ui.md 中,INV-UI-1 被定义为一条Status: locked(已锁定)的产品不变量:
Statement:Purple is off-brand. UI accents and primary actions use white/neutral treatments, not purple hues, glows, or gradients.
紫色是偏离品牌(off-brand)的。UI 强调色与主操作应使用白色/中性处理,而非紫色调、辉光或渐变。
"locked" 状态意味着它属于仓库product/invariants/目录下的产品不变量体系——这类规则一旦锁定,改动必须走专门流程(详见后文 PR 规则)。Friend 项目是一款"AI that sees your screen, listens to your conversations and tells you what to do"的 AI 助手,其视觉语言刻意保持克制的中性风格,紫色被明确定义为品牌禁区。
仓库根目录 AGENTS.md 的 Cross-Component Guidelines 中亦同步了这条红线:
Never use purpleanywhere in UI (icons, accents, glows, gradients) — off-brand; use white/neutral. Enforced as a no-increase ratchet (
INV-UI-1); seeproduct/invariants/brand-ui.md.
即:界面中的图标、强调色、辉光、渐变等任何位置都不允许紫色;强制执行方式为INV-UI-1的无增长棘轮。
二、MUST NOT:被明确禁止的行为清单
文档以MUST NOT形式列出的红线非常具体,共两条:
- 禁止在 UI 中使用紫色——包括图标(icons)、强调色(accents)、辉光(glows)、悬停状态(hover states)、渐变(gradients)。
- 禁止引入新的紫色系统色或品牌 token——即不得新增
purplePrimary、purpleGradient之类的语义色命名。
值得注意的是第二条与"允许存量债务存在"的棘轮机制配合使用:历史遗留的紫色引用可以在迁移期内存在,但新增紫色(无论是新文件还是已有文件中提高计数)都会导致检查失败。
三、适用范围:哪些界面受约束
INV-UI-1 覆盖三块端侧界面(Surfaces):
| 端 | 技术栈 | 覆盖范围 |
|---|---|---|
| 桌面端 | SwiftUI | OmiTheme及桌面视图(desktop/macos/Desktop/Sources/) |
| 移动 App | Flutter | app/lib/下的全部 UI 代码 |
| Web | 营销/管理后台 | web/下以 Omi 品牌名义上线的界面 |
对应的Path globs(路径匹配)被写进文档,同时也被检查脚本作为UI_ROOTS常量原样复用(见.github/scripts/check_brand_ui.py第 20–24 行):
desktop/macos/Desktop/Sources/** app/lib/** web/**这些路径与检查脚本中的UI_ROOTS一一对应,说明文档与代码守卫是"契约—实现"的强绑定关系,而非松散的约定。
四、Guard 测试:check_brand_ui.py 的"无增长棘轮"机制
文档明确指出守卫脚本为.github/scripts/check_brand_ui.py。这是本不变量的核心工程实现,值得深入拆解。
4.1 棘轮(ratchet)的核心语义
脚本 docstring 开宗明义(第 1–8 行):
Compares purple-hit counts in changed UI sources against the merge base. Existing debt may remain; introducing new purple (raising a file's count, or adding purple in a new file) fails.
即:只比较本次变更文件在 PR 基线上的紫色命中数变化。存量紫色债务可以继续存在;但某个文件中的紫色命中数一旦上升,或新文件引入紫色,检查即失败。这正是"无增长棘轮"的含义——它不追求一步清零,而是保证紫色使用量只减不增。
4.2 文件过滤:is_ui_source
在统计紫色之前,脚本先过滤"哪些文件需要被监管"(is_ui_source,第 81–91 行),同时满足四个条件才算 UI 源文件:
- 不在
ALLOWLIST_FILES允许列表中; - 路径以
UI_ROOTS任一前缀开头(desktop/macos/Desktop/Sources/、app/lib/、web/); - 扩展名属于
UI_SUFFIXES = {".swift", ".dart", ".ts", ".tsx", ".js", ".jsx", ".css"}; - 路径中不含
.git、node_modules、build、dist、.next、__pycache__等跳过目录。
4.3 检测模式:从关键字到色相判定的三层防线
count_purple函数(第 115–121 行)采用穷举模式 + 色相判定的双层计数,每一层都对应一个真实发生过的漏检事故。
第一层:显式模式匹配(PURPLE_PATTERNS,第 39–66 行)
- 平台 API:
Color.purple、.purple(SwiftUI 简写,如.foregroundStyle(.purple))、Colors.purple(Flutter); - Flutter 特有拼写:
\bdeepPurple(?:Accent)?\b——注释说明\bpurple\b因 camelCase 无词边界无法命中deepPurple,曾导致漏检; - 十六进制字面量:
#7C3AED、#8B5CF6等 9 个枚举紫色色值(PURPLE_HEX_HUES,第 37 行),以及 Dart 风格的0x(?:[0-9A-F]{2})?(?:7C3AED|...)(0xFF8B5CF6形态,lookbehind 要求0x必须作为 token 起点,防止命中foo0x8B5CF6这类无关常量); - 语义 token:
purplePrimary|purpleSecondary|purpleAccent|purpleLight|purpleGradient|purpleLightGradient; - Tailwind/CSS:
purple-500类名、--purple-CSS 变量、color: purple、字符串'Purple'; - 邻近色族:
\b(?:violet|indigo|fuchsia)-\d{2,4}\b——注释还原了事故经过:violet/indigo 拼写与 purple 完全不同,之前的模式全部漏过,导致 Web 市场(marketplace)曾上线 violet 促销卡片与两个 violet 分类主题而检查仍报 OK。
第二层:HSV 色相判定(is_purple_hex,第 106–112 行)
由于"枚举色值"永远追不上真实世界,脚本引入了第二个更智能的判定:对任何 6 位十六进制字面量(HEX_LITERAL,第 96 行)做 HSV 转换,判断其是否落在"人眼感知为紫色"的区间:
PURPLE_HUE_RANGE = (235.0, 320.0):HSV 色相在蓝色到品红之间视为紫色;MIN_SATURATION = 0.25、MIN_VALUE = 0.20:低于该饱和度和明度的颜色(如#1A1A1A)即使色相计算值落在区间内,也会被视为中性色而非紫色。
注释里记录了一个经典的漏检案例:#6C2BD9与枚举值#6D28D9只差一个数字,App Store 开发者横幅的紫色渐变就曾携带这种色值绕过"成员判定"通过检查——色相判定正是为此补上的兜底防线。
4.4 计数与比较:棘轮如何"证明不增长"
主流程main()(第 131–166 行)逻辑清晰:
- 解析参数
--changed-files(必填,列出变更文件)、--base(必填,合并基线 git ref)、--root(仓库根,默认.); - 读取变更文件列表并过滤出 UI 源文件;若无任何 UI 源文件变更则直接
OK; - 对每个变更文件:读取当前工作区内容统计
head_count,用git show <base>:<path>取出基线内容统计base_count; - 若
head_count > base_count,记录回归(path: purple hits base → head); - 存在回归则输出
FAIL: INV-UI-1 — purple usage increased in changed UI files.并列出明细,退出码 1;否则输出OK: INV-UI-1 — no purple increase across N changed UI file(s).,退出码 0。
命令行调用形态为:
python3 .github/scripts/check_brand_ui.py \ --changed-files /tmp/changed.txt \ --base origin/main \ --root .其中--base缺失时会直接FAIL,因为棘轮必须依赖基线内容才能判断"是否增长"。
4.5 允许列表(Allowlist):债务的受控出口
脚本ALLOWLIST_FILES(第 30–33 行)目前仅含一个路径:
ALLOWLIST_FILES: set[str] = { # Theme token definitions still expose purple* names during migration. "desktop/macos/Desktop/Sources/Theme/OmiColors.swift", }被允许的原因注释得很清楚:这是迁移期间仍暴露purple*命名的旧主题 token 定义文件。同时脚本明确要求:允许列表应优先收缩而非扩张——如需新增条目,必须在 PR 中引用 INV-UI-1 说明理由。
有趣的是,仓库中该文件当前实际内容(desktop/macos/Desktop/Sources/Theme/OmiColors.swift)显示旧的OmiColors深色寄存器已经被删除:原硬编码近黑背景(backgroundPrimary#0F0F0F、textPrimary#FFFFFF)在改为 light-pinned 玻璃面板(InkGlass)后全部失效,项目选择删除而非重新着色或别名("a silent invisible surface is the one failure a build cannot report"),视图改用Ink(颜色)、InkType(字体)以及PageGlass/GlassShell/NotchGlass(表面词汇)表达。文件仅保留通用的Color(hex:)便利初始化器。这从侧面印证了不变量生态的运作方式:文档声明红线,脚本执行棘轮,而存量债务通过 allowlist 与迁移逐步清除。
五、测试佐证:test_check_brand_ui.py
仓库为count_purple、is_purple_hex、is_ui_source提供了单元测试(.github/scripts/test_check_brand_ui.py),其中每个测试都对应一次真实事故或边界设计:
| 测试用例 | 验证点 |
|---|---|
test_counts_color_purple_and_hex | Color.purple、0x8B5CF6、#8B5CF6、purplePrimary均计数 |
test_counts_flutter_deep_purple | Colors.deepPurple/deepPurpleAccent/.shade300命中 |
test_counts_dart_hex_literal | Color(0xFF8B5CF6)、Color(0xff7c3aed)命中 |
test_ignores_colours_that_merely_contain_purple_hex_digits | 0xFF1A2B3C、0xFF00FF00不计数(防止误伤) |
test_dart_hex_literal_must_start_a_token | foo0x8B5CF6、SOME_CONST0x8B5CF6不计数,value = 0x8B5CF6;计数 |
test_is_ui_source | 桌面 Sources 为 UI 源;backend/main.py不是;allowlist 文件不算 |
test_counts_swiftui_dot_purple | .foregroundStyle(.purple)命中 |
test_counts_tailwind_bg_purple_500 | bg-purple-500 text-purple-700计 2 |
test_counts_tailwind_ramps_that_are_purple_by_sight_not_by_name | bg-violet-500 text-indigo-300计 2、from-fuchsia-600计 1 |
test_hex_is_judged_by_hue_not_by_membership | #6C2BD9、#2D1B69等未枚举色值按色相判定命中 |
test_counts_css_swift_and_dart_indigo_literals | #6366F1(indigo 色值)计 1 |
这些测试既锁定了检测器的行为契约,也把"为什么需要这层检测"的历史事故固化为回归防线,属于典型的"不变量 + 守卫测试 + 事故驱动补丁"闭环。
六、与其他守卫的联动:guardrail_pulse.py 的品牌指标
INV-UI-1 的守卫并非孤立存在。.github/scripts/guardrail_pulse.py导入了check_brand_ui模块(第 25 行),在其_brand_ui_metric(第 102–119 行)中直接复用UI_ROOTS、is_ui_source与count_purple,遍历仓库根下全部 UI 源文件并累加紫色命中数,产出名为brand_ui_purple的度量指标(第 119 行)。
这意味着仓库存在两条互补的监控视角:
- PR 级的棘轮(check_brand_ui.py):阻止单个 PR 使紫色使用量上升;
- 全局脉冲度量(guardrail_pulse.py):持续观测整个仓库的紫色总量,量化"债务消减"的长期趋势。
同一套判定函数被两处复用,也保证了"局部拦截"与"全局度量"的判定口径完全一致。
七、PR 规则:何时需要在 PR 中显式点名 INV-UI-1
文档的PR rule刻意区分了两种情况:
- 常规 UI PR 不需要在标题或描述中点名
INV-UI-1——因为品牌棘轮已经作为 CI 守卫自动兜底,"品牌地板由 ratchet 强制保证"。 - 只有两类场景需要显式点名:一是故意变更品牌配色政策(例如调整品牌色方向);二是修改允许列表(
ALLOWLIST_FILES)。这两种操作本质上是在"修改不变量本身",因此必须走显式引用流程。
这与仓库根 AGENTS.md 的跨组件规范一致:"如果你的 diff 触及某条锁定不变量的 path globs,必须在 PR body 中点名每一个命中的不变量 ID(基于路径而非意图),并在行为变化时更新该不变量的守卫测试。"可借助scripts/pr-preflight --suggest自动发现命中项。
八、实践建议:在 Friend 仓库中如何遵守与自查
结合文档与源码,给读者一份可执行的自查清单:
- 改动前:先确认改动文件是否落入
desktop/macos/Desktop/Sources/**、app/lib/**、web/**三个 glob 之一;若命中,本不变量即刻生效。 - 编码时:强调色与主操作使用白色/中性色(如
Ink、PageGlass等桌面端表面词汇,或 Flutter/Web 的中性 token);不引入任何purple*语义命名,不使用#7C3AED、#8B5CF6、#A855F7、#9333EA、#6D28D9、#AF52DE、#D946EF、#A78BFA、#C4B5FD等紫色系色值,也避免 Tailwind 的violet-*、indigo-*、fuchsia-*色阶。 - 本地验证:运行单元测试确认检测器行为符合预期——
python3 .github/scripts/test_check_brand_ui.py(位于.github/scripts/目录下);需要完整走一遍棘轮时可准备变更文件清单并指定基线执行check_brand_ui.py。 - 存量债务:若某文件已存在紫色且本次改动会自然触碰它,优先顺带消减;严禁在同一 PR 中让该文件计数上升。
- 允许列表:除非确有迁移期理由,否则不要向
ALLOWLIST_FILES新增路径;必须新增时在脚本注释中说明原因,并在 PR 描述中显式点名 INV-UI-1。 - 策略变更:任何品牌色政策的调整都属于不变量变更,须显式引用 INV-UI-1 并同步更新守卫脚本与测试。
九、小结:一条不变量的完整工程闭环
从 brand-ui.md 我们可以看到一个"产品不变量"在 Friend 仓库中的完整生命周期:
- 语义层:一句话锁定品牌方向——"No purple; neutral accents",状态 locked;
- 约束层:MUST NOT 清单 + Surfaces + Path globs,明确"禁什么、管哪里";
- 执行层:
.github/scripts/check_brand_ui.py以"无增长棘轮"方式在 CI 中拦截新增紫色,模式匹配 + HSV 色相判定双层防漏,allowlist 提供受控债务出口; - 验证层:
.github/scripts/test_check_brand_ui.py把历次漏检事故固化为回归用例; - 度量层:
.github/scripts/guardrail_pulse.py持续统计brand_ui_purple全局总量; - 协作层:PR 规则区分"常规改动(自动守卫)"与"策略变更(显式点名)",与根目录 AGENTS.md 的 invariants 流程衔接。
对任何要在 Friend 仓库中提交 UI 改动的开发者而言,理解 INV-UI-1 及其棘轮实现,等于同时理解了品牌红线本身、CI 拦截机制,以及"如何在债务与红线之间做受控的渐进式迁移"——这正是仓库把一条视觉规则工程化为可强制执行、可度量、可持续消减的系统性做法。
【免费下载链接】FriendAI that sees your screen, listens to your conversations and tells you what to do项目地址: https://gitcode.com/GitHub_Trending/fr/Friend
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考