Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
3c32baf
feat(design-system): DS-3 最小语义 Token 影子层
kirozeng Sep 2, 2026
9d5d2b5
fix-run ds3-r1b wave1 squash
kirozeng Sep 2, 2026
77db609
fix(design-tokens): HSL 色值改用标准 DTCG 颜色对象,修复 Windows CRLF 假红
kirozeng Sep 2, 2026
a1c4b8c
fix(design-tokens): 零接线守卫按文件位置解析相对 import,校验器检查分量范围
kirozeng Sep 2, 2026
fe37161
fix(design-tokens): 相对说明符提取补副作用 import 语境
kirozeng Sep 2, 2026
d555e03
fix(design-tokens): 零接线守卫剥离注释与字符串里的伪导入
kirozeng Sep 2, 2026
8f481c0
fix(design-tokens): 零接线守卫剥除顺序前置,修复注释隔断的合法导入漏检
kirozeng Sep 2, 2026
d8fbb53
fix(design-tokens): 导入语境判定去长度窗口,member-require 不再被后顾排除
kirozeng Sep 2, 2026
ae0e685
fix(design-tokens): 零接线扫描纳入 require.resolve 加载入口
kirozeng Sep 2, 2026
d9246db
fix(design-tokens): 零接线扫描纳入模板字面量形式的模块说明符
kirozeng Sep 2, 2026
f0a7998
Merge origin/main into ds/3-token-shadow-layer + new URL 直接读取纳入零接线扫描
kirozeng Sep 2, 2026
3aa65b4
fix(design-tokens): 语义豁免色登记 exemption 元数据,fs 裸相对路径直读纳入零接线扫描
kirozeng Sep 2, 2026
838d4d7
fix(design-tokens): 零接线扫描纳入路径构造器与 Windows 反斜杠说明符
kirozeng Sep 2, 2026
5b404b2
fix(design-tokens): 路径构造器重建全部静态参数,Tier-1 受保护 slot 照常语义建模
kirozeng Sep 2, 2026
f26072c
fix(design-tokens): 零接线扫描纳入 import.meta.glob,语境判定锚定开引号
kirozeng Sep 2, 2026
947e8e8
fix(design-tokens): 零接线扫描纳入 fs/promises.open 与 .mts/.cts 源码
kirozeng Sep 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
.githooks/** text eol=lf
# SQLite migration 内容参与用户数据库 drift hash;跨平台统一 LF。
apps/desktop/drizzle/**/*.sql text eol=lf
# design-tokens 影子层生成物参与「磁盘 = 内存生成」字节一致守卫;Windows
# autocrlf 检出转 CRLF 会让该守卫假红(classification.test.ts 实测),固定 LF。
packages/design-tokens/src/**/*.json text eol=lf
# sqlite-vec 扩展二进制(平台特定 .dylib / .dll)—— 走 Git LFS
apps/desktop/native/sqlite-vec/**/*.dylib filter=lfs diff=lfs merge=lfs -text
apps/desktop/native/sqlite-vec/**/*.dll filter=lfs diff=lfs merge=lfs -text
Expand Down
1 change: 1 addition & 0 deletions docs/design-rules/cindy-design-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@

- **2026-09-02(Desktop 登录成功回调页 UX 覆盖)**:成功态移除返回 Cindy 按钮,改为 560×500 紧凑内容流卡片,底部显示本地化 3 秒倒计时并在结束时先移除文字再调用 `window.close()`;失败 / Warning 继续使用 680×680 卡片与返回操作。同步更新 `DESIGN.md §16`、`figma-component-spec.md §6`、`token-decision-table.md §4` 与客户端模板测试。

- **2026-09-02(DS-3 最小语义 Token 影子层)**:新建 `packages/design-tokens`(标准 DTCG JSON,reference → semantic 两层)。数据源为 DS-2b 冻结快照,零运行时接线;弃坑复查日期 2026-11-01。不改台账、不改产品代码。路线图 DS-2b 回填为 #3700。
- **2026-08-31(Switch / 通用手柄交稿包入仓)**:登记 `gamepads/nintendo-switch-pro/`、`gamepads/switch-joy-con/`、`gamepads/ultimate-c1/` 三组同事线稿,设置页 Nintendo 默认 Switch Pro、接上 Joy-Con 时换 Joy-Con 图,通用手柄用 Ultimate C1。零视觉规范改写。
- **2026-08-30(治理合同修订:管道/记账、已知边界、路线图勘误;同日按 review 收口)**:[`design-governance.md`](./design-governance.md) 新增 §1.1「管道与记账」(守卫红灯不是禁令——管道规则不许绕、记账值走「同 PR 更新快照/台账 + 设计师批」的合法路径改,消灭的是「没人决定过的变化」;**保护值例外**:CINDY 皮肤族 / U2 二级信息色 / `annotation-accent` 不适用通用路径,须按 `DESIGN.md` 各自的用户裁决或冻结条款;正式豁免登记是合法路径,只禁未经裁决为消红灯加豁免)与 §13「已知边界」(正则扫描边界——内联样式字面量会被 `hardcoded-color-audit` 发现、真正扫不到的是动态值与 canvas/xterm 自绘;复用道路唯一靠 review 不靠机器;**新代码默认走语义层**、保留 §3.3/§3.4 既有准入、仅存量渐进),两节自
mivo-canvas-plugin 仓 4/7 张 PR 实战沉淀移植。勘误三处:§2 旧编号「PR-8」→「DS-8」;§12 依赖行 DS-6 前置由已关闭的圆角裁决改为「Permission 迁移余项」并补 DS-7 受 `radius` 覆盖裁决约束;§12 路线图回填 DS-1 = #3609(合入日期修正为 2026-08-30)。零视觉,非 DS 序号。
Expand Down
7 changes: 4 additions & 3 deletions docs/design-rules/design-governance.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ protected 标签、目标道路、下一动作

### 3.1 目标层级(DTCG)

`packages/design-tokens`(由后续 Token PR 建立)采用标准 DTCG JSON,三层:
`packages/design-tokens`(DS-3 已建影子层,零运行时接线;DS-8 才生产生成切换)采用标准 DTCG JSON,三层:

```text
reference 原始值:色阶、字号、字重、间距、圆角、动效时长
Expand Down Expand Up @@ -179,7 +179,8 @@ Primitive 与 Pattern 默认只绑定 semantic 角色。只有品牌表达、兼
| CI 门禁调整 | 新增/升级检查、required 名单变动 | 先报告后阻断 + 管理员人工审核(§8) |

每张 PR 必须写明独立回退方式;任一阶段结束时仓库必须不劣于开始状态。影子 Token 包
在约定复查期内没有真实消费者时应删除,不长期并存。已登记缺口不得描述为已完成能力;
在约定复查期内没有真实消费者时应删除,不长期并存(DS-3 弃坑复查日期 **2026-11-01**,
详见 `packages/design-tokens/README.md`)。已登记缺口不得描述为已完成能力;
缺口未修复前,对应验收矩阵格不得记为通过。

## 8. 治理接线纪律
Expand Down Expand Up @@ -253,7 +254,7 @@ ls apps/desktop/src/renderer/themes/builtin/*.ts | wc -l
| --- | --- | --- | --- |
| DS-1 | `docs(design-system): DS-1 建立治理合同与存量门禁处置表` | 零视觉(纯文档) | ✅ #3609(2026-08-30 合入) |
| DS-2a | `test(design-system): DS-2a 生产 UI 台账` | 零视觉 | ✅ #3648(2026-08-31 合入) |
| DS-2b | `ci(design-system): DS-2b 主题兼容冻结守卫`(新增阻断需 §8 管理员审核) | CI 门禁 | |
| DS-2b | `ci(design-system): DS-2b 主题兼容冻结守卫`(新增阻断需 §8 管理员审核) | CI 门禁 | ✅ #3700(2026-09-02 合入) |
| DS-3 | `feat(design-system): DS-3 最小语义 Token 影子层` | 零视觉 | — |
| DS-4 | `feat(design-system): DS-4 Button 与 Input 标准组件`(落入既有 `components/ui/`) | 有意可见 | — |
| DS-5 | `refactor(design-system): DS-5 AI 对话区 Pattern 迁移`(Tool Call / Reasoning / Message / Attachment) | 有意可见 | — |
Expand Down
63 changes: 63 additions & 0 deletions packages/design-tokens/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# @cindy/design-tokens

Cindy 设计 token 的 **DTCG 影子层**(reference → semantic)。

本包只是字典,**零运行时接线**:Desktop / Mobile / 任何产品 package 都不得依赖它。生产生成切换在路线图 **DS-8**;在那之前,Desktop 颜色数值权威仍是 `apps/desktop/src/renderer/themes/colors.ts`,本包的取值必须与 DS-2b 冻结快照逐值一致。

## 弃坑条款

治理合同 §7:影子包在约定复查期内没有真实消费者时应删除。

- **复查日期:2026-11-01**
- 届时若 DS-4 仍未开工,整包删除(revert 本 PR 即可,零运行时残留)
- 等真做 DS-4 时再重建,不让没人消费的字典变成第三份漂移真相

## 数据源

分类与建层的唯一数据源是 DS-2b 冻结快照:

`apps/desktop/src/renderer/themes/__tests__/fixtures/desktop-color-defaults.json`

不重新解析 `colors.ts`。重新生成:在本包目录执行 `pnpm generate`(脚本 `src/generate.ts`)。连续两次生成必须字节一致。守卫测试会核对。

## 两层

| 层 | 路径 | 内容 |
| --- | --- | --- |
| reference | `src/reference/color.json` | 第一批 semantic 角色实际引用的原始色值(不铺全色板) |
| semantic | `src/semantic/color.json` | DESIGN.md §10 Tier-1 的 surface / border / text / accent 四族 + status 语义;每个角色 light/dark = 冻结快照现值 |

依赖单向:semantic → reference。不建 component 层(DS-4)。不装 Terrazzo(DS-8)。

色值一律用标准 DTCG 颜色对象(`$type: "color"` + `{colorSpace, components[, alpha]}`):
HSL triplet(`60 12.5% 97%`)→ `{"colorSpace":"hsl","components":[60,12.5,97]}`;
hex / rgba / transparent → srgb 分量(0–1)+ 可选 alpha。不用自定义 `$type`
(`"other"` 不是标准 DTCG 类型——Terrazzo 2.7.1 实测会静默丢弃这类 token,
DS-8 接线时无法生成 CSS 变量;裸 triplet 字符串也会被解析成黑色)。

加严保护值按治理合同 §1.1 标记 **protected**,分两种 mode:Tier-1 slot(U2 二级信息色 `text-secondary` / `text-secondary-cross`)按 §3.2「名称与用途延续」**照常 semantic 建模** + protected 元数据——保护限制的是改值须经裁决,不是禁止迁移;Tier-3 singleton(`annotation-accent`、CINDY 皮肤族品牌红 `login-brand-accent` / `login-brand-accent-pressed`)按「保留原位,逐项裁决,默认不动」只登记、不建模。皮肤族其余值在 cindy-light/dark 主题 override 里,不在本快照默认值中。

语义豁免色(DESIGN.md §10 theme-invariant 族:`destructive` / `error-*` / `warning-*` / `focus-ring*`)与 protected 不同:**照常 semantic 建模**,但在 `classification.json` 携带 `exemption` 元数据(外部主题不可覆盖、跨主题恒定)。DS-8 生成主题入口时据此区分可覆写 semantic 与必须保留原值的豁免族;治理合同 §3.2 要求 Tier-3 豁免色按此迁移。DESIGN.md §10 豁免表其余未建模项(`diff-*` / `login-error-fg` 等)进 shadow 层时再登记。

## 多入口投放合同(只写合同,DS-8 才接线)

每个消费者届时只消费自己的子集,由同一份 DTCG 生成,不得再手写第二份数值:

| 消费者 | DS-8 起消费什么 |
| --- | --- |
| Desktop ColorRegistry | semantic + 后续 component;`registerColor` 的 id / 默认 light/dark 由生成物提供 |
| CSS Variables(`:root` / `theme-vars`) | 同一套 semantic id 的 kebab-case CSS 变量 |
| Tailwind 映射 | 现有 `tailwind.config.ts` 色名继续指向上述 CSS 变量,不另造色板 |
| Mobile TS Token | `apps/mobile/src/theme/tokens.ts` 改为引用生成子集;须另立高风险 PR(冷更边界) |
| DESIGN.md 机器摘要 | §10 Tier-1 表与 §16.1 登录表由本包生成摘要替换人工维护 |

本张不产出任何被产品消费的生成物。

## 分类登记

`src/classification.json` 覆盖冻结快照全部 id,四类互斥完备:

1. **literal** — 直接数值,reference 层候选
2. **alias** — `var(--…)` / `hsl(var(--…))`,semantic/component 候选
3. **hsl-triplet** — `-hsl` 后缀族,与对应 hex 必须指同一颜色(DS-8 起由生成器保证)
4. **runtime-derived-or-protected** — 运行期计算值、非颜色、双模式不全、加严保护值;只登记存在、负责人与去向,不建模
19 changes: 19 additions & 0 deletions packages/design-tokens/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"name": "@cindy/design-tokens",
"version": "0.0.0",
"private": true,
"description": "Cindy design-token shadow layer (DTCG reference + semantic). Zero runtime consumers until DS-8.",
"type": "module",
"scripts": {
"generate": "node --experimental-strip-types src/generate.ts",
"build": "tsc --noEmit",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"license": "Apache-2.0",
"devDependencies": {
"@types/node": "*",
"typescript": "^5.7.0",
"vitest": "^3.2.4"
}
}
211 changes: 211 additions & 0 deletions packages/design-tokens/src/__tests__/classification.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
import { execFileSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';

import {
classifyColor,
classifySnapshot,
PROTECTED_IDS,
SEMANTIC_EXEMPTION_IDS,
stableStringify,
type ClassificationCategory,
} from '../classify.ts';
import { buildShadowLayerFiles } from '../generate.ts';
import {
assertClassificationCoversSnapshot,
assertProtectedNotSemantic,
assertSemanticExemptionsRegistered,
} from '../guards.ts';
import {
classificationPath,
findRepoRoot,
referencePath,
semanticPath,
} from '../paths.ts';
import { readSnapshot } from '../snapshot.ts';

describe('DS-3 · 分类登记', () => {
const repoRoot = findRepoRoot();
const snapshot = readSnapshot(repoRoot);
const generated = classifySnapshot(snapshot.colors, snapshot.source);
const onDisk = JSON.parse(readFileSync(classificationPath(repoRoot), 'utf8'));

it('覆盖冻结快照全部 id,计数等于 fixture.count,四类互斥完备', () => {
expect(snapshot.colors.length).toBe(snapshot.count);
assertClassificationCoversSnapshot(generated, snapshot);
expect(generated.snapshotCount).toBe(snapshot.count);
const sum = Object.values(generated.categories).reduce((a, b) => a + b, 0);
expect(sum).toBe(snapshot.count);
});

it('两次生成字节一致', () => {
const first = stableStringify(classifySnapshot(snapshot.colors, snapshot.source));
const second = stableStringify(classifySnapshot(snapshot.colors, snapshot.source));
expect(first).toBe(second);
expect(stableStringify(onDisk)).toBe(first);
});

it('磁盘上的影子层与内存生成字节一致', () => {
const built = buildShadowLayerFiles(repoRoot);
expect(built.files).toEqual([
{ path: classificationPath(repoRoot), body: stableStringify(generated) },
{ path: referencePath(repoRoot), body: stableStringify(built.layers.reference) },
{ path: semanticPath(repoRoot), body: stableStringify(built.layers.semantic) },
]);
for (const file of built.files) {
expect(readFileSync(file.path, 'utf8')).toBe(file.body);
}
});

it('生成物 JSON 检出行尾固定 LF(.gitattributes 已钉 eol=lf,Windows autocrlf 不会转 CRLF)', () => {
// CI 实锤(2026-09-02 Windows unit tests 红):core.autocrlf=true 的检出把
// 生成物 JSON 转成 CRLF 后,上一条「磁盘 = 内存生成」字节一致守卫假红。
// 修复 = .gitattributes 给 packages/design-tokens/src/**/*.json 钉 eol=lf
// (drizzle migration .sql 同款先例)。本测试钉住该契约:一旦有人删掉
// .gitattributes 规则,这里用 git check-attr 直接红灯,不再等 Windows CI。
for (const relPath of [
'packages/design-tokens/src/classification.json',
'packages/design-tokens/src/reference/color.json',
'packages/design-tokens/src/semantic/color.json',
]) {
const attrs = execFileSync(
'git',
['check-attr', 'eol', '--', relPath],
{ cwd: repoRoot, encoding: 'utf8' },
).trim();
expect(attrs, `${relPath} 应被 .gitattributes 钉 eol=lf,实际: ${attrs}`).toBe(
`${relPath}: eol: lf`,
);
}
});

it('加严保护值标记 protected,Tier-1 照常建模、Tier-3 只登记', () => {
assertProtectedNotSemantic(generated);
for (const id of Object.keys(PROTECTED_IDS)) {
const entry = generated.entries.find((item) => item.id === id);
expect(entry?.protected).toBeTruthy();
if (PROTECTED_IDS[id].mode === 'register-only') {
// Tier-3 singleton:只登记、不建模(治理合同 §3.2「保留原位」)。
expect(entry?.category).toBe('runtime-derived-or-protected');
expect(entry?.modeledAsSemantic).toBe(false);
} else {
// Tier-1 slot:照常 semantic 建模 + protected 元数据(治理合同
// §3.2「名称与用途延续」;保护限制改值,不禁止迁移——review P2 实锤:
// 旧统一 register-only 分支让 text-secondary 从 semantic 消失,
// DS-8 无法从新真相源生成它)。
expect(entry?.modeledAsSemantic).toBe(true);
expect(entry?.category).not.toBe('runtime-derived-or-protected');
}
}
});

it('语义豁免色照常建模并携带 exemption 元数据(review P2 补洞)', () => {
// DESIGN.md §10 theme-invariant 豁免族(destructive / error-* / warning-* /
// focus-ring*)与 PROTECTED_IDS 不同:照常 semantic 建模,但外部主题
// 不可覆盖。DS-8 生成主题入口时靠 exemption 元数据区分可覆写 semantic
// 与必须保留原值的豁免族——缺标记时生成端无法区分(review P2 实锤)。
assertSemanticExemptionsRegistered(generated);
for (const id of Object.keys(SEMANTIC_EXEMPTION_IDS)) {
const entry = generated.entries.find((item) => item.id === id);
expect(entry?.modeledAsSemantic, `${id} 应保持 semantic 建模`).toBe(true);
expect(entry?.exemption?.family, `${id} 应带 exemption 元数据`).toBe(
'semantic-exemption',
);
expect(entry?.protected, `${id} 不应同时是 protected(那是「只登记不建模」)`).toBeFalsy();
}
// 反证:普通可覆写 semantic(如 surface)不带豁免标记。
const surface = generated.entries.find((item) => item.id === 'surface');
expect(surface?.exemption).toBeUndefined();
});

it('独立 oracle:分类类别必须与快照值实际形态语义一致,不能只看 id 后缀', () => {
// 独立于被测实现复刻分类语义口径(刻意不复用 classify.ts 的
// classifyValue / isLiteralKind,改用自己的正则判定,分类规则变更时
// 必须与本测试同步更新):
// protected 且 register-only(Tier-3)→ runtime-derived-or-protected;
// protected 且 semantic-modeled(Tier-1)→ 按值形态正常分类;
// -hsl 后缀且双模式都是 hsl-triplet → hsl-triplet;
// 双 alias → alias;双字面量 → literal;其余一律 runtime-derived。
const TRIPLET_VALUE_RE = /^-?\d+(?:\.\d+)?\s+\d+(?:\.\d+)?%\s+\d+(?:\.\d+)?%$/;
const ALIAS_VALUE_RE = /^(?:hsl\()?var\(--[a-z0-9-]+\)\)?$/;
const HEX_VALUE_RE = /^#([0-9a-fA-F]{3,8})$/;
const RGB_VALUE_RE = /^rgba?\(/;
const HSL_FN_VALUE_RE = /^hsla?\(/;

const isAlias = (value: string | null) =>
value != null && ALIAS_VALUE_RE.test(value.trim());
const isTriplet = (value: string | null) =>
value != null && TRIPLET_VALUE_RE.test(value.trim());
const isLiteral = (value: string | null) => {
if (value == null) return false;
const text = value.trim();
return (
HEX_VALUE_RE.test(text) ||
RGB_VALUE_RE.test(text) ||
HSL_FN_VALUE_RE.test(text) ||
text === 'transparent' ||
isTriplet(value)
);
};

const expected = new Map<string, ClassificationCategory>();
for (const color of snapshot.colors) {
let category: ClassificationCategory;
const protectedRule = PROTECTED_IDS[color.id];
if (protectedRule && protectedRule.mode === 'register-only') {
// Tier-3 singleton:只登记(治理合同 §3.2「保留原位」)。
category = 'runtime-derived-or-protected';
} else if (
color.id.endsWith('-hsl') &&
isTriplet(color.light) &&
isTriplet(color.dark)
) {
category = 'hsl-triplet';
} else if (isAlias(color.light) && isAlias(color.dark)) {
category = 'alias';
} else if (isLiteral(color.light) && isLiteral(color.dark)) {
// Tier-1 semantic-modeled 的保护值(text-secondary 等)按值形态
// 正常分类(literal),不因 protected 标记改变 category。
category = 'literal';
} else {
category = 'runtime-derived-or-protected';
}
expected.set(color.id, category);
}
for (const entry of generated.entries) {
expect(
entry.category,
`id=${entry.id} category=${entry.category} 与独立判定 ${expected.get(entry.id)} 不一致`,
).toBe(expected.get(entry.id));
}
});

it('反证:-hsl 后缀但值非 triplet 的合成条目不许被分进 hsl-triplet', () => {
// 旧实现只看 -hsl 后缀就归 hsl-triplet;现在必须按双模式实际值判定。
const hslSuffixedButLiteral = classifyColor({
id: 'something-hsl',
light: '#000000',
dark: '#ffffff',
});
expect(hslSuffixedButLiteral.category).toBe('literal');
const hslSuffixedButAlias = classifyColor({
id: 'something-hsl',
light: 'var(--surface)',
dark: 'var(--surface)',
});
expect(hslSuffixedButAlias.category).toBe('alias');
const hslSuffixedButSingleMode = classifyColor({
id: 'something-hsl',
light: '60 12.5% 97%',
dark: '#ffffff',
});
expect(hslSuffixedButSingleMode.category).toBe('literal');
// 真实快照中全部 -hsl id 确实双模式都是 triplet(登记与现状一致)
const realHslIds = snapshot.colors.filter((color) => color.id.endsWith('-hsl'));
expect(realHslIds.length).toBeGreaterThan(0);
for (const color of realHslIds) {
expect(color.light).toMatch(/^-?\d+(?:\.\d+)?\s+\d+(?:\.\d+)?%\s+\d+(?:\.\d+)?%$/);
expect(color.dark).toMatch(/^-?\d+(?:\.\d+)?\s+\d+(?:\.\d+)?%\s+\d+(?:\.\d+)?%$/);
}
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"$description": "error fixture: reference aliases a semantic token (wrong direction)",
"hex-ffffff": {
"$type": "color",
"$value": "{surface.surface.light}"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"$description": "error fixture: illegal $type",
"broken": {
"$type": "not-a-dtcg-type",
"$value": "#ffffff"
}
}
Loading