main:语义化圆角从视觉约定变成仓库契约

Buffin · b3a13bd → 14dcbfe · 2026-07-17 · 自包含,读完即弃

3 commits
69 文件
+1556 / −171
31.8% 是测试代码

写法说明:本文按「逐跳走读」展开——每个机制都给真实代码片段(取自分支、经裁剪,青色斜体注释为解读所加,灰色斜体是源码原注释的保留或意译),每段代码标注所在文件。每条旅程结尾有一张「排查路标」:将来出问题时,症状对应去哪个文件看哪个函数。

1TL;DR

这组变更把 renderer 的圆角从 rounded-mdrounded-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变更地图

checker
799 行 · 46.3%
tests
550 行 · 31.8%
renderer
369 行 · 21.4%
metadata
9 行 · 0.5%
区域设计重心(要细读)可放心略过
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 与它们同处一个静态设计层。

apps/desktop/src/renderer/styles/tokens.css真实代码(节选)
@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数值相同;前者用于等宽高圆形,后者用于可变宽胶囊
同值不同义是刻意保留的能力circlepill 都是 9999rem,但未来可分别治理消费者。检查器禁止重复名称,不禁止不同语义共享同一个值。

4旅程 A:一个组件如何选到合法圆角

这条旅程从设计角色开始,经过 Tailwind utility,最后落到具体组件。关键变化不是像素,而是源码开始保存“为什么是这个圆角”。

全景 · 涉及 4 类文件
定义角色
tokens.css
Tailwind v4 生成 utility 组件选角色
*.tsx
检查 scope
tokenViolation()

A.1事实源同时服务构建与检查

以前的简单检查器从 TypeScript Tailwind 配置里找 borderRadius 表;当前实现改为用 PostCSS 读取 tokens.css@theme。因此 CSS 生成 utility 的来源与检查允许表是同一个文件。

scripts/check-radius-tokens.mjs策略解析
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:小尺寸也有明确名称
TaskCard.tsx · FloatingComposer.tsx · TabStrip.tsx三个层级样本
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

marktabmessagecomposer 的定义前带 radius-token-scope:。检查时,文件路径被归一化为 renderer 相对路径并只移除最后一个真实扩展名;同名备份文件与点号后缀文件不会冒充正式消费者。

scripts/check-radius-tokens.mjstokenViolation()
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 被报 unknowntokens.css:检查 @theme 是否定义该语义名
合法 token 被报 outside scopetokenViolation():比较注释中的源码 ID 与 renderer 相对路径
组件视觉尺寸变化先看消费者选择的角色,再回到 tokens.css 看该角色数值

5旅程 B:一次全仓检查如何走完

这条旅程从 bun run check 进入,先读取 policy,再按扩展名分流。JS-family 走 ts-morph,CSS 走 PostCSS,HTML 走属性提取加 PostCSS inline declaration 解析。

全景 · 从命令到诊断
check:radius-tokens
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。

scripts/check-radius-tokens.mjslistScannedFiles()
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。

scripts/check-radius-tokens.mjscreateSourceScan()
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 = ...

scripts/check-radius-tokens.mjs动态 class 组装
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-classdata-style。style 属性的内容被包进临时 rule,再交给 PostCSS 解析。

scripts/check-radius-tokens.mjs按扩展名分流
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 中的修复把它从“能发现常规违规”推进到“常见逃逸写法也无法静默通过”。这部分最适合用旧假设与当前结果对照。

第一版
上一行只要含 marker 字符串就可豁免
scope 用文件路径包含关系
动态 class 主要覆盖模板;style 覆盖 JSX 对象
rounded-concentric 是特权 utility
当前提交
语言 scanner 确认真实注释与物理相邻
scope 是唯一 renderer 源码 ID 的精确匹配
模板、+、concat、join、computed key、DOM style 都覆盖
同心计算走带理由的普通豁免;不再有专用特权

数值契约也发生了一个重要调整:第一版禁止两个语义 token 共享同值;当前允许同值不同义,于是圆点使用 circle,可变宽胶囊使用 pill。角色可以以后独立演化,而无需先伪造数值差异。

scripts/check-radius-tokens.test.mjs审查问题转成长期规格
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():确认真实注释、前缀、理由和相邻行
备份文件意外获得强语义 tokentokenViolation() 与 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.25remcontrol 0.375rem、surface 0.5rem、overlay 0.625rem落地值保持现有界面密度,强圆角只留给 message 与 composer
阴影 token 替换 generic shadow三个 shadow token 已定义,消费者仍使用 shadow-xl/2xl本次只建立定义,没有迁移阴影消费者
同心圆作为可推导关系移除 rounded-concentric utility没有生产消费者;需要时用计算值加相邻理由
语义约束主要依赖约定强 token 带唯一源码 scope角色互换从评审问题变成静态诊断

8心智模型补丁

圆角来自 Tailwind 默认尺寸表 圆角来自 tokens.css 的 Tailwind v4 @theme
改一个 token 值会影响所有同角色消费者,也会改变检查器允许表。
rounded-md 只是一个视觉选择 rounded-control 是源码保存的角色判断
选择错误不会总被静态检查发现;未 scoped 的 token 仍依赖设计评审。
任意圆角要么全面禁止,要么完全放开 任意几何可以存在,但必须用真实相邻注释留下理由
新文件在 git add 之前不属于仓库门禁 未跟踪 renderer 文件也由 git ls-files --others 扫描
圆点和胶囊都叫 full 同一个 9999rem 分成 circlepill 两种语义
本变更新词表
radius policy@theme 解析出的 token 值与源码 scope 映射
source ID去掉 renderer 前缀和真实扩展名后的精确组件路径
radius-token-exception允许局部裸几何的原因凭据
radius-corner-shape使用 squircle 等曲线增强时的独立理由凭据
variant chainhover: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 violationsbun run check:radius-tokens 通过。
验收时别被这些吓到rounded-t-tabtab 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-*: initialradius namespace reset must precede tokens
wildcard reset 不是 initial--radius-*: inheritradius namespace reset must use initial
reset 后没有语义 token@theme { --radius-*: initial }no semantic radius tokens found
名称仍是尺寸刻度--radius-md--radius-2xl--radius-full--radius-4radius token … must use a semantic name;改用角色名。
同名 token 重复定义两个 --radius-controlduplicate radius token;同值不同角色允许,同名不允许。
值不是非负 rem6px50%-0.25remcalc(...)must be a nonnegative rem value;只接受 0 或非负 rem 数值。
数值没有升序排列surface: .5rem 后再写 control: .375remradius tokens must appear in ascending numeric order;相同值可相邻。
一个 scope 枚举多个消费者radius-token-scope: A, Bscope must name one exact source;一个强语义 token 只绑定一个精确源码 ID。

10.2Tailwind Utility 与动态 class

不合法情况例子诊断
省略语义后缀roundedrounded-tbare radius utility
使用任意圆角 utilityrounded-[0.4rem]arbitrary radius;需要相邻 radius-token-exception: 理由。
使用未定义或默认刻度rounded-mdrounded-unknownunknown radius token
强语义 token 用错文件TaskCard.tsx 使用 rounded-composersemantic radius token outside its source scope;文件名包含关键字或点号备份都不会放行。
模板插值组装 token`rounded-${radius}`dynamic radius;检查器无法静态证明最终角色。
加号拼接 token'rounded-' + radiusdynamic 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 tokenoutside its source scope
corner-shape 无同状态 fallbackhover: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 radiusdynamic inline radius
style object 的 radius 自定义属性写裸值{ '--vendor-radius': '0.5rem' }需要相邻 radius-token-exception: 理由;混合大小写与下划线也会命中。
style / CSS / HTML 引用未知或越界 tokenborder-radius: var(--radius-md)unknown radius tokenoutside its source scope;标准属性与自定义 radius 属性都适用。
CSS radius declaration 写裸值BORDER-RADIUS: 13pxraw CSS radius;标准 CSS 属性名按大小写不敏感处理。
多值 radius 中混入裸值、未知或越界 tokenborder-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 radiusdata-style 不误报。
CSS / HTML / React 中 corner-shape 缺前置 fallbackCORNER-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-controlvariant chain 不一致,基础态没有可用 fallback。
scope 路径只做模糊相似FloatingComposer.backup.tsx源码 ID 精确匹配;包含关键词、不同目录或点号后缀都算越界。
这不是“任意语法都能抓”的声明:运行时 helper、style.setProperty() 等未进入 AST 规则的形态仍是盲区;本章只穷举当前检查器明确判错的路径。

11覆盖声明

本报告以 b3a13bd 为 base,覆盖三个圆角相关提交,共 69 个文件。主力逐行阅读了 799 行检查器、550 行测试、tokens 与 package 接线,以及所有 renderer 消费者迁移。

明确排除了工作区中与 Session Markdown 播放相关的四个 tracked 文件和一个 untracked hook;它们不属于语义化圆角旅程。消费者区域的 class 替换全部进入阅读范围,但报告只展开能更新心智模型的代表样本。