main:语义化圆角从视觉约定变成仓库契约
Buffin · b3a13bd → 14dcbfe · 2026-07-17 · 自包含,读完即弃
写法说明:本文按「逐跳走读」展开——每个机制都给真实代码片段(取自分支、经裁剪,青色斜体注释为解读所加,灰色斜体是源码原注释的保留或意译),每段代码标注所在文件。每条旅程结尾有一张「排查路标」:将来出问题时,症状对应去哪个文件看哪个函数。
1TL;DR
这组变更把 renderer 的圆角从 rounded-md、rounded-full 和任意值,迁移成按角色命名的 Tailwind v4 token。组件现在表达“我是控件、表面、浮层、消息、Composer、圆点或胶囊”,不再表达“我要第几个尺寸”。
同一份 tokens.css 既生成 Tailwind utility,也是检查器的事实源。检查器覆盖 TS/JS AST、CSS、HTML、动态字符串和 inline style;裸值只有在相邻真实注释里写明理由才可通过。
后续审查把第一版的宽松 scope、可伪造注释和多种动态绕过收紧,并把 JS-family 文件改成一次解析、一次 AST 遍历。最终专项测试为 37/37,通过 live renderer 扫描。
2变更地图
| 区域 | 设计重心(要细读) | 可放心略过 |
|---|---|---|
scripts/ | 事实源解析、AST 扫描、豁免、scope、文件发现 | 无;这里承载主要行为 |
tokens.css | 语义词表、Tailwind 默认 namespace 重置、阴影角色 | 字体与 color-scheme 未改变 |
| renderer 消费者 | 少数强角色:mark、tab、message、composer、circle/pill | 多数是等价的 class 名迁移 |
| 元数据 | check:radius-tokens 接入仓库门禁 | .pi-subagents ignore 与 Base UI lock pin 不属于圆角行为 |
3当前契约:名字描述角色
Tailwind v4 的 @theme 是唯一事实源。默认的 xs 到 4xl 被显式清空,随后定义 11 个语义圆角;raised、floating、overlay 三个阴影 token 与它们同处一个静态设计层。
@theme {
--radius-xs: initial;
--radius-sm: initial;
/* 其余 Tailwind 默认圆角同样 reset */
/* radius-token-scope: components/ui/highlighted-text */
--radius-mark: 0.125rem;
--radius-indicator: 0.1875rem;
--radius-compact: 0.25rem;
--radius-control: 0.375rem;
--radius-surface: 0.5rem;
--radius-overlay: 0.625rem;
--radius-tab: 0.75rem;
--radius-message: 1rem;
--radius-composer: 1.25rem;
--radius-circle: 9999rem;
--radius-pill: 9999rem;
}
| 尺寸内的角色 | |
|---|---|
mark / indicator | 文字高亮与微型选择标记 |
compact / control | 密集按钮、菜单行与常规交互控件 |
surface / overlay | 内容表面与临时浮层 |
tab / message / composer | 具有唯一产品角色的强语义表面 |
circle / pill | 数值相同;前者用于等宽高圆形,后者用于可变宽胶囊 |
circle 与 pill 都是 9999rem,但未来可分别治理消费者。检查器禁止重复名称,不禁止不同语义共享同一个值。
4旅程 A:一个组件如何选到合法圆角
这条旅程从设计角色开始,经过 Tailwind utility,最后落到具体组件。关键变化不是像素,而是源码开始保存“为什么是这个圆角”。
tokens.css→ Tailwind v4 生成 utility→ 组件选角色
*.tsx→ 检查 scope
tokenViolation()
A.1事实源同时服务构建与检查
以前的简单检查器从 TypeScript Tailwind 配置里找 borderRadius 表;当前实现改为用 PostCSS 读取 tokens.css 的 @theme。因此 CSS 生成 utility 的来源与检查允许表是同一个文件。
export function parseRadiusPolicy(content, filePath) {
const root = postcss.parse(content, { from: filePath })
const policy = new Map()
let previousValue = -1
root.walkAtRules('theme', (theme) => {
theme.walkDecls((declaration) => {
const name = declaration.prop
.match(/^--radius-([a-z][a-z0-9-]*)$/)?.[1]
if (name === undefined) return
if (defaultRadiusNames.test(name)) {
throw new Error(`${filePath}: radius token ${name} must use a semantic name`)
}
// 名称、单位、顺序和 scope 都在这里进入 policy
})
})
return policy
}
A.2消费者迁移保存了视觉角色
rounded-md:按钮、卡片、菜单共享尺寸名rounded-full:圆点与胶囊共享几何名rounded-[0.25rem]:局部值没有角色rounded-control / surface / overlay:按层级选择rounded-circle / pill:按几何角色区分rounded-mark / indicator:小尺寸也有明确名称const CARD_SURFACE =
'... rounded-surface border border-border bg-card ...'
className={cn(
'composer-shell w-full rounded-composer border border-accent bg-content',
density === 'narrow' ? '' : 'max-w-[45rem]',
)}
active
? 'rounded-t-tab bg-content text-ink'
: 'text-ink-soft hover:text-ink'
A.3强语义 token 绑定精确源码 ID
mark、tab、message、composer 的定义前带 radius-token-scope:。检查时,文件路径被归一化为 renderer 相对路径并只移除最后一个真实扩展名;同名备份文件与点号后缀文件不会冒充正式消费者。
const rendererRelative = normalizedPath.includes(rendererMarker)
? normalizedPath.slice(normalizedPath.indexOf(rendererMarker) + rendererMarker.length)
: normalizedPath
const segments = rendererRelative.split('/')
segments[segments.length - 1] = segments.at(-1)
.replace(/\.(?:[cm]?[jt]sx?)$/i, '')
const sourceId = segments.join('/')
if (definition.scopes.length > 0 && !definition.scopes.includes(sourceId)) {
return 'semantic radius token outside its source scope'
}
排查路标 · 旅程 A
| 症状 | 从哪下手 |
|---|---|
rounded-xxx 被报 unknown | tokens.css:检查 @theme 是否定义该语义名 |
| 合法 token 被报 outside scope | tokenViolation():比较注释中的源码 ID 与 renderer 相对路径 |
| 组件视觉尺寸变化 | 先看消费者选择的角色,再回到 tokens.css 看该角色数值 |
5旅程 B:一次全仓检查如何走完
这条旅程从 bun run check 进入,先读取 policy,再按扩展名分流。JS-family 走 ts-morph,CSS 走 PostCSS,HTML 走属性提取加 PostCSS inline declaration 解析。
package.json→ load policy
tokens.css→ discover files
git ls-files→ scan by syntax
AST / PostCSS / HTML→ path:line diagnostic
B.1扫描集合包含未提交新文件
文件发现使用 git ls-files --cached --others --exclude-standard,因此新建但尚未 git add 的 renderer 文件也在门禁内;随后用文件系统状态剔除已删除的 tracked path。
const paths = execFileSync(
'git',
['ls-files', '--cached', '--others', '--exclude-standard', '--', ...scanRoots],
{ cwd: rootDir, encoding: 'utf8' },
)
.split('\n')
.filter(Boolean)
.filter((path) => scanExtensions.test(path))
.filter((path) => {
const absolute = join(rootDir, path)
return existsSync(absolute) && statSync(absolute).isFile()
})
B.2JS-family 文件只解析一次
createSourceScan() 在一次 AST 遍历中收集字符串、模板、二元表达式、调用表达式与 style 属性节点。class 检查和 inline style 检查复用同一个索引,避免同一文件重复 parse。
function createSourceScan(content, filePath) {
const sourceFile = parser.createSourceFile(filePath, content, { overwrite: true })
const scan = {
binaryExpressions: [],
callExpressions: [],
propertyAssignments: [],
shorthandPropertyAssignments: [],
stringLiterals: [],
templateExpressions: [],
}
sourceFile.forEachDescendant((node) => {
// 每个相关 SyntaxKind 只在这里入桶一次
})
return scan
}
静态字符串直接检查 rounded-*;模板插值、+、concat() 与空分隔符 join('') 则进入动态序列检查。style 路径覆盖对象属性、计算属性、简写属性和 element.style.borderRadius = ...。
for (const call of sourceScan.callExpressions) {
const callee = call.getExpression().asKind(SyntaxKind.PropertyAccessExpression)
if (callee?.getName() === 'concat') {
scanDynamicSequence(
[callee.getExpression(), ...call.getArguments()],
call.getStartLineNumber(),
)
continue
}
if (callee?.getName() !== 'join') continue
// ['rounded-', radius].join('') 也不能绕过
}
B.3CSS 与 HTML 各用自己的语法边界
CSS declaration 由 PostCSS 提供属性名、值、兄弟节点和源码行。普通 CSS 属性按大小写不敏感处理;包含 radius 的自定义属性保留自己的名称规则;@apply 参数回到 Tailwind class 扫描。
HTML 支持 quoted、unquoted 与大小写属性名,同时用属性名前置边界排除 data-class 和 data-style。style 属性的内容被包进临时 rule,再交给 PostCSS 解析。
const hits = path.endsWith('.css')
? findCssRadiusViolations(content, path, policy)
: path.endsWith('.html')
? findHtmlRadiusViolations(content, path, policy)
: findSourceRadiusViolations(content, path, policy)
for (const hit of hits) {
violations.push(`${path}:${hit.line} ${hit.reason}: ${hit.value}`)
}
B.4逃逸不是禁止,而是必须留下理由
裸几何需要相邻上一行的 radius-token-exception:。JS/TS 使用 TypeScript scanner 确认它是真实注释,CSS 使用 PostCSS sibling,HTML 使用 HTML comment;普通字符串里的伪造前缀不会放行。
corner-shape 使用独立的 radius-corner-shape:,并要求先有 radius fallback。Tailwind 下 fallback 与 corner-shape 的 variant chain 必须完全一致,基础态不会借用 hover 态的 fallback。
// radius-token-exception: derives from the parent radius and inset
const style = { borderRadius: computedRadius }
// radius-corner-shape: branded media thumbnail
const className = 'rounded-surface [corner-shape:squircle]'
排查路标 · 旅程 B
| 症状 | 从哪下手 |
|---|---|
| 新文件没有被检查 | listScannedFiles():看 scanRoots、扩展名和 git exclude |
| 动态 class 漏报或误报 | findRadiusClassViolations():看对应 AST 表达式是否进入 scanDynamicSequence |
| inline style 诊断异常 | findInlineStyleRadiusViolations():区分对象属性、shorthand 与 DOM assignment |
| CSS/HTML 行号不对 | findCssRadiusViolations() 或 findHtmlRadiusViolations():看 PostCSS source 与 startLine 偏移 |
6旅程 C:两轮审查怎样收紧第一版
主提交先建立完整门禁,随后 worktree 中的修复把它从“能发现常规违规”推进到“常见逃逸写法也无法静默通过”。这部分最适合用旧假设与当前结果对照。
rounded-concentric 是特权 utility数值契约也发生了一个重要调整:第一版禁止两个语义 token 共享同值;当前允许同值不同义,于是圆点使用 circle,可变宽胶囊使用 pill。角色可以以后独立演化,而无需先伪造数值差异。
it('rejects radius utilities assembled through concat and join', ...)
it('requires restricted semantic tokens to stay in their declared source scope', ...)
it('checks computed style keys and DOM style assignments', ...)
it('checks case-insensitive CSS radius and corner-shape properties', ...)
it('ignores class and style text inside data attributes', ...)
it('requires a corner-shape fallback under the same Tailwind variants', ...)
排查路标 · 旅程 C
| 症状 | 从哪下手 |
|---|---|
| 豁免注释看似存在仍被拒绝 | hasAdjacentSourceReason():确认真实注释、前缀、理由和相邻行 |
| 备份文件意外获得强语义 token | tokenViolation() 与 scope 回归测试 |
| 扫描耗时突然增长 | createSourceScan() 与 findSourceRadiusViolations():确认单文件仍只 parse 一次 |
7计划与实际落地的偏差
随附的 Visual Surface Token Design 是 proposed 设计稿;当前代码保留其“按角色命名”的核心,但实现已经根据真实消费者细化。
| 设计稿 | 实际落地 | 发生了什么 |
|---|---|---|
inline / control / surface / overlay / composer 五层 | 扩为 11 个 radius 角色 | 真实 UI 需要 mark、indicator、tab、message、circle 与 pill 等可追踪角色 |
| control 0.5rem、surface 0.625rem、overlay 1.25rem | control 0.375rem、surface 0.5rem、overlay 0.625rem | 落地值保持现有界面密度,强圆角只留给 message 与 composer |
| 阴影 token 替换 generic shadow | 三个 shadow token 已定义,消费者仍使用 shadow-xl/2xl | 本次只建立定义,没有迁移阴影消费者 |
| 同心圆作为可推导关系 | 移除 rounded-concentric utility | 没有生产消费者;需要时用计算值加相邻理由 |
| 语义约束主要依赖约定 | 强 token 带唯一源码 scope | 角色互换从评审问题变成静态诊断 |
8心智模型补丁
tokens.css 的 Tailwind v4 @theme
rounded-md 只是一个视觉选择
rounded-control 是源码保存的角色判断
git ls-files --others 扫描
circle 与 pill 两种语义
| 本变更新词表 | |
|---|---|
radius policy | 从 @theme 解析出的 token 值与源码 scope 映射 |
source ID | 去掉 renderer 前缀和真实扩展名后的精确组件路径 |
radius-token-exception | 允许局部裸几何的原因凭据 |
radius-corner-shape | 使用 squircle 等曲线增强时的独立理由凭据 |
variant chain | hover:、focus: 等 Tailwind 条件组成的完整状态前缀 |
9测试与风险地图
有兜底的
- token 名称、单位、重复名、升序与 namespace reset
- 裸 utility、任意值、未知 token、方向与 important
- 模板、拼接、concat、join 等动态 class
- style object、computed key、shorthand、DOM assignment
- CSS declaration、custom property、
@apply、HTML 属性 - 真实注释、精确 scope、corner-shape 同状态 fallback
- 未跟踪文件与已删除文件的 dirty-worktree 行为
事实上的薄冰
- 🟡没有视觉截图或浏览器 E2E 证明角色选择的最终观感
- 🟡未 scoped 的 token 可在语义不合适的组件中合法出现
- 🟡运行时 helper、
style.setProperty()等未列 AST 形态不在现有测试矩阵 - ⚪生产 renderer 当前没有
corner-shape消费者,相关规则由 fixture 固定 - ⚪shadow token 目前只有定义,没有生产消费者或专用门禁
scripts/check-radius-tokens.test.mjs 37/37 通过;live renderer 扫描 339 passed / 0 failed / 0 violations;bun run check:radius-tokens 通过。
rounded-t-tab 是 tab token 的方向 utility,不是未知 token;circle 与 pill 同值是语义拆分;shadow token 无消费者是本次范围的预留事实,不代表 radius 管线未接线。
10当前检查器判定为不合法的全部情况
这里的“全部”指 14dcbfe 中检查器已经编码的违规分支,不把尚未覆盖的运行时写法假装成已检测能力。同一条底层规则会在不同语法入口分别列出,便于看到具体代码形态会收到什么诊断。
10.1Tailwind v4 Token 定义
| 不合法情况 | 例子 | 检查结果 / 合法做法 |
|---|---|---|
| namespace 没有 reset | @theme { --radius-control: .375rem } | radius namespace reset is missing;先 reset 默认 radius namespace。 |
| reset 写在 token 后面 | --radius-control: ...; --radius-*: initial | radius namespace reset must precede tokens。 |
| wildcard reset 不是 initial | --radius-*: inherit | radius namespace reset must use initial。 |
| reset 后没有语义 token | @theme { --radius-*: initial } | no semantic radius tokens found。 |
| 名称仍是尺寸刻度 | --radius-md、--radius-2xl、--radius-full、--radius-4 | radius token … must use a semantic name;改用角色名。 |
| 同名 token 重复定义 | 两个 --radius-control | duplicate radius token;同值不同角色允许,同名不允许。 |
| 值不是非负 rem | 6px、50%、-0.25rem、calc(...) | must be a nonnegative rem value;只接受 0 或非负 rem 数值。 |
| 数值没有升序排列 | surface: .5rem 后再写 control: .375rem | radius tokens must appear in ascending numeric order;相同值可相邻。 |
| 一个 scope 枚举多个消费者 | radius-token-scope: A, B | scope must name one exact source;一个强语义 token 只绑定一个精确源码 ID。 |
10.2Tailwind Utility 与动态 class
| 不合法情况 | 例子 | 诊断 |
|---|---|---|
| 省略语义后缀 | rounded、rounded-t | bare radius utility。 |
| 使用任意圆角 utility | rounded-[0.4rem] | arbitrary radius;需要相邻 radius-token-exception: 理由。 |
| 使用未定义或默认刻度 | rounded-md、rounded-unknown | unknown radius token。 |
| 强语义 token 用错文件 | TaskCard.tsx 使用 rounded-composer | semantic radius token outside its source scope;文件名包含关键字或点号备份都不会放行。 |
| 模板插值组装 token | `rounded-${radius}` | dynamic radius;检查器无法静态证明最终角色。 |
| 加号拼接 token | 'rounded-' + radius | dynamic radius。 |
concat() 拼接 token | 'rounded-'.concat(radius) | dynamic radius。 |
join('') 拼接 token | ['rounded-', radius].join('') | dynamic radius。 |
| 任意 radius 属性写裸值 | [border-radius:13px] | raw arbitrary radius property;引用合法 token 或写相邻例外理由。 |
| 任意自定义属性含 radius 且写裸值 | [--panel_Radius:0.5rem] | custom radius token arbitrary value requires … comment;名称匹配大小写、连字符和下划线。 |
| 任意属性引用未知或越界 token | [border-radius:var(--radius-md)] | unknown radius token 或 outside its source scope。 |
corner-shape 无同状态 fallback | hover:rounded-control [corner-shape:squircle] | fallback 与 corner-shape 的 variant chain 必须完全相同,且需要专用理由。 |
10.3React Style、CSS 与 HTML
| 不合法情况 | 例子 | 诊断 / 覆盖入口 |
|---|---|---|
| inline style 写裸值 | { borderRadius: '0.5rem' } | raw inline radius;物理角和逻辑角属性同样检查。 |
| inline style 使用动态值 | { borderTopLeftRadius: radius } | dynamic inline radius。 |
| inline style 使用 shorthand | { borderRadius } | dynamic inline radius。 |
| 计算属性写裸值 | { ['borderRadius']: '13px' } | raw inline radius。 |
| DOM style assignment 写裸值或动态值 | element.style.borderRadius = '13px' | raw inline radius 或 dynamic inline radius。 |
| style object 的 radius 自定义属性写裸值 | { '--vendor-radius': '0.5rem' } | 需要相邻 radius-token-exception: 理由;混合大小写与下划线也会命中。 |
| style / CSS / HTML 引用未知或越界 token | border-radius: var(--radius-md) | unknown radius token 或 outside its source scope;标准属性与自定义 radius 属性都适用。 |
| CSS radius declaration 写裸值 | BORDER-RADIUS: 13px | raw CSS radius;标准 CSS 属性名按大小写不敏感处理。 |
| 多值 radius 中混入裸值、未知或越界 token | border-radius: var(--radius-control) 8px | 任一水平/垂直半径不合法,整条 declaration 违规。 |
| CSS 自定义 radius 属性写裸值 | --vendor-radius: 0.5rem | 每个 declaration 都要有自己的相邻例外理由;一条注释不会连续放行两项。 |
@apply 使用非法圆角 | @apply rounded-md | 复用 utility 规则,得到 unknown radius token 等对应诊断。 |
| HTML class 使用非法圆角 | <DIV CLASS=rounded> | quoted、unquoted、大小写属性名都复用 utility 规则;data-class 不误报。 |
| HTML style 写裸值 | <div style="border-radius:8px"> | raw inline radius;data-style 不误报。 |
CSS / HTML / React 中 corner-shape 缺前置 fallback | CORNER-SHAPE: squircle | 必须先声明 radius fallback,并紧邻 radius-corner-shape: 理由。 |
10.4豁免、Scope 与 Corner Shape 凭据
| 不合法情况 | 例子 | 为什么不放行 |
|---|---|---|
| 没有豁免注释 | 裸值直接出现 | 任意几何必须留下局部理由。 |
| 前缀后没有理由 | // radius-token-exception: | marker 不是理由;前缀后必须有非空文本。 |
| 注释不在紧邻上一行 | 注释与目标之间有空行或代码 | 只认目标前一个物理行,防止文件级大范围放行。 |
| 用普通字符串伪造注释 | const note = 'radius-token-exception: bypass' | TS scanner 只认真实注释 token。 |
| 使用了错误的注释语法 | HTML 用 JS 注释,CSS 用普通字符串 | JS/TS、CSS、HTML 分别由语言 scanner、PostCSS sibling、HTML comment 识别。 |
给 corner-shape 使用普通 radius 例外前缀 | radius-token-exception: 放在 squircle 前 | 曲线增强只认 radius-corner-shape:,并仍要求 fallback。 |
| fallback 顺序错误 | corner-shape 在 radius declaration 前 | CSS、HTML style 与 React object 都要求 fallback 更早出现。 |
| Tailwind fallback 状态不一致 | 基础态 curve 借用 hover:rounded-control | variant chain 不一致,基础态没有可用 fallback。 |
| scope 路径只做模糊相似 | FloatingComposer.backup.tsx | 源码 ID 精确匹配;包含关键词、不同目录或点号后缀都算越界。 |
style.setProperty() 等未进入 AST 规则的形态仍是盲区;本章只穷举当前检查器明确判错的路径。
11覆盖声明
本报告以 b3a13bd 为 base,覆盖三个圆角相关提交,共 69 个文件。主力逐行阅读了 799 行检查器、550 行测试、tokens 与 package 接线,以及所有 renderer 消费者迁移。
明确排除了工作区中与 Session Markdown 播放相关的四个 tracked 文件和一个 untracked hook;它们不属于语义化圆角旅程。消费者区域的 class 替换全部进入阅读范围,但报告只展开能更新心智模型的代表样本。