@q1travel/design-system 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/.agents/skills/product-design/AGENTS.md +9 -0
  2. package/.agents/skills/product-design/SKILL.md +63 -0
  3. package/.agents/skills/product-design/exemplars/README.md +12 -0
  4. package/.agents/skills/product-design/references/copy.md +7 -0
  5. package/.agents/skills/product-design/references/coverage-gaps.md +9 -0
  6. package/.agents/skills/product-design/references/glossary.md +5 -0
  7. package/.agents/skills/product-design/references/interface-quality.md +7 -0
  8. package/.agents/skills/product-design/references/patterns.md +13 -0
  9. package/.agents/skills/product-design/references/product-judgment.md +7 -0
  10. package/.agents/skills/product-design/references/resilience.md +13 -0
  11. package/.agents/skills/product-design/references/review-geist.md +94 -0
  12. package/.agents/skills/product-design/references/rules.md +17 -0
  13. package/.agents/skills/product-design/references/surfaces-forms.md +14 -0
  14. package/.agents/skills/product-design/references/surfaces-overlays.md +12 -0
  15. package/.agents/skills/product-design/references/surfaces.md +14 -0
  16. package/bin/q1-design.mjs +464 -0
  17. package/contracts/design-engineering-principles.md +39 -0
  18. package/contracts/design.dark.md +333 -0
  19. package/contracts/design.md +333 -0
  20. package/contracts/icon-system.md +77 -0
  21. package/contracts/implementation-map.md +29 -0
  22. package/contracts/interface-guidelines.md +122 -0
  23. package/contracts/token-naming.md +82 -0
  24. package/{dist → dist-pkg}/design-system.d.ts +36 -26
  25. package/{dist → dist-pkg}/design-system.js +222 -216
  26. package/{dist → dist-pkg}/style.css +2 -2
  27. package/package.json +19 -9
  28. package/tooling/README.md +9 -0
  29. package/tooling/scripts/evals/fixtures.json +7 -0
  30. package/tooling/scripts/evals/rules-checklist.json +23 -0
  31. package/tooling/scripts/evals/transition-all/after/example.css +4 -0
  32. package/tooling/scripts/evals/transition-all/before/example.css +4 -0
@@ -0,0 +1,464 @@
1
+ #!/usr/bin/env node
2
+ // Consumer-side CLI for the design-authority npm seam (decision 0001):
3
+ // `sync` materializes the package's canonical skill + contracts into a
4
+ // read-only cache under the consumer repo; `verify` proves that cache was
5
+ // never hand-edited and still matches the installed package version.
6
+
7
+ import { createRequire } from 'node:module';
8
+ import {
9
+ cpSync,
10
+ existsSync,
11
+ lstatSync,
12
+ mkdirSync,
13
+ readFileSync,
14
+ readdirSync,
15
+ renameSync,
16
+ rmSync,
17
+ statSync,
18
+ writeFileSync,
19
+ } from 'node:fs';
20
+ import { createHash } from 'node:crypto';
21
+ import { dirname, extname, join, relative, resolve, sep } from 'node:path';
22
+ import { fileURLToPath } from 'node:url';
23
+
24
+ const PACKAGE_NAME = '@q1travel/design-system';
25
+ const SKILL_RELATIVE_DIR = '.claude/skills/product-design';
26
+ const MANIFEST_FILE_NAME = '.q1-design-manifest.json';
27
+ const SKILL_SOURCE_SUBPATH = '.agents/skills/product-design';
28
+ const CONTRACTS_SOURCE_SUBPATH = 'contracts';
29
+
30
+ const LINT_EXTENSIONS = new Set(['.css', '.js', '.jsx', '.ts', '.tsx', '.mjs']);
31
+ const DASH_DS_PATTERN = /--ds-/;
32
+ const TRANSITION_ALL_PATTERN = /transition(?:-property)?\s*:\s*all\b/;
33
+ const HEX_COLOR_PATTERN = /#[0-9a-fA-F]{3,8}\b/;
34
+ const COLOR_FUNCTION_PATTERN = /\b(?:rgb|rgba|hsl)\s*\(/i;
35
+
36
+ function resolveInstalledPackageRoot(consumerRootDir) {
37
+ const require = createRequire(join(consumerRootDir, 'package.json'));
38
+ let packageJsonPath;
39
+ try {
40
+ packageJsonPath = require.resolve(`${PACKAGE_NAME}/package.json`);
41
+ } catch {
42
+ return null;
43
+ }
44
+ return dirname(packageJsonPath);
45
+ }
46
+
47
+ function readInstalledPackageVersion(packageRootDir) {
48
+ const packageJson = JSON.parse(
49
+ readFileSync(join(packageRootDir, 'package.json'), 'utf8'),
50
+ );
51
+ return packageJson.version;
52
+ }
53
+
54
+ function toPosixPath(pathValue) {
55
+ return pathValue.split(sep).join('/');
56
+ }
57
+
58
+ function listFilesRecursively(rootDir) {
59
+ const relativeFilePaths = [];
60
+
61
+ function walk(currentDir) {
62
+ for (const entryName of readdirSync(currentDir).sort()) {
63
+ const entryPath = join(currentDir, entryName);
64
+ if (statSync(entryPath).isDirectory()) {
65
+ walk(entryPath);
66
+ } else {
67
+ relativeFilePaths.push(toPosixPath(relative(rootDir, entryPath)));
68
+ }
69
+ }
70
+ }
71
+
72
+ if (existsSync(rootDir)) {
73
+ walk(rootDir);
74
+ }
75
+ return relativeFilePaths;
76
+ }
77
+
78
+ function computeFileHash(filePath) {
79
+ return createHash('sha256').update(readFileSync(filePath)).digest('hex');
80
+ }
81
+
82
+ function assertNotSymlink(pathValue) {
83
+ let stats;
84
+ try {
85
+ stats = lstatSync(pathValue);
86
+ } catch {
87
+ return; // 不存在,无需检查
88
+ }
89
+ if (stats.isSymbolicLink()) {
90
+ console.error(
91
+ `[q1-design] ${pathValue} 是符号链接,拒绝在其上删除/写入;请先手工处理该链接再重试。`,
92
+ );
93
+ process.exit(1);
94
+ }
95
+ }
96
+
97
+ function runSync(consumerRootDir) {
98
+ // 防误删:必须在项目根(有 package.json)运行,否则会把任意 cwd 下的
99
+ // .claude/skills/product-design 当缓存删掉。
100
+ if (!existsSync(join(consumerRootDir, 'package.json'))) {
101
+ console.error(
102
+ `[q1-design] 当前目录不在项目根(缺 package.json): ${consumerRootDir}`,
103
+ );
104
+ process.exit(1);
105
+ }
106
+
107
+ const packageRootDir = resolveInstalledPackageRoot(consumerRootDir);
108
+ if (!packageRootDir) {
109
+ console.error(
110
+ `[q1-design] 未安装 ${PACKAGE_NAME},请先 npm install ${PACKAGE_NAME}`,
111
+ );
112
+ process.exit(1);
113
+ }
114
+
115
+ const skillCacheDir = join(consumerRootDir, SKILL_RELATIVE_DIR);
116
+ const skillsParentDir = dirname(skillCacheDir);
117
+
118
+ // 防误删:.claude、.claude/skills、目标目录任一是 symlink 都拒绝——
119
+ // 否则 rmSync/rename 可能穿透链接抹掉链接指向的真实目录。
120
+ assertNotSymlink(join(consumerRootDir, '.claude'));
121
+ assertNotSymlink(skillsParentDir);
122
+ assertNotSymlink(skillCacheDir);
123
+
124
+ // 防误删:目标已存在但不含 manifest ⇒ 不是本工具生成的目录,拒绝覆盖。
125
+ if (
126
+ existsSync(skillCacheDir) &&
127
+ !existsSync(join(skillCacheDir, MANIFEST_FILE_NAME))
128
+ ) {
129
+ console.error(
130
+ `[q1-design] ${skillCacheDir} 已存在且缺少 ${MANIFEST_FILE_NAME},目录非本工具生成;如确认可覆盖,请先手动删除该目录再重试。`,
131
+ );
132
+ process.exit(1);
133
+ }
134
+
135
+ // 事务化:先在 .claude/skills/ 下建临时目录,完整复制 + 写 manifest 成功后,
136
+ // 才 rm 旧缓存 → rename 就位;中途任何失败都清理临时目录,旧缓存保留。
137
+ mkdirSync(skillsParentDir, { recursive: true });
138
+ const tempDir = join(skillsParentDir, `product-design.tmp-${process.pid}`);
139
+
140
+ let fileHashes;
141
+ let packageVersion;
142
+ try {
143
+ rmSync(tempDir, { recursive: true, force: true });
144
+ mkdirSync(tempDir, { recursive: true });
145
+
146
+ cpSync(join(packageRootDir, SKILL_SOURCE_SUBPATH), tempDir, {
147
+ recursive: true,
148
+ });
149
+ cpSync(
150
+ join(packageRootDir, CONTRACTS_SOURCE_SUBPATH),
151
+ join(tempDir, 'contracts'),
152
+ { recursive: true },
153
+ );
154
+
155
+ packageVersion = readInstalledPackageVersion(packageRootDir);
156
+ fileHashes = {};
157
+ for (const relativeFilePath of listFilesRecursively(tempDir)) {
158
+ fileHashes[relativeFilePath] = computeFileHash(
159
+ join(tempDir, relativeFilePath),
160
+ );
161
+ }
162
+
163
+ const manifest = {
164
+ package: PACKAGE_NAME,
165
+ version: packageVersion,
166
+ generatedAt: new Date().toISOString(),
167
+ files: fileHashes,
168
+ };
169
+ writeFileSync(
170
+ join(tempDir, MANIFEST_FILE_NAME),
171
+ `${JSON.stringify(manifest, null, 2)}\n`,
172
+ );
173
+
174
+ rmSync(skillCacheDir, { recursive: true, force: true });
175
+ renameSync(tempDir, skillCacheDir);
176
+ } catch (error) {
177
+ rmSync(tempDir, { recursive: true, force: true });
178
+ console.error(`[q1-design] sync 失败,旧缓存保留: ${error.message}`);
179
+ process.exit(1);
180
+ }
181
+
182
+ const syncedFileCount = Object.keys(fileHashes).length;
183
+ console.log(`[q1-design] synced ${syncedFileCount} files`);
184
+ console.log(`[q1-design] version: ${packageVersion}`);
185
+ console.log(`[q1-design] target: ${skillCacheDir}`);
186
+ console.log(
187
+ '[q1-design] 该目录是只读缓存,勿手改;建议加入 .gitignore,变更请改包版本后重新 sync。',
188
+ );
189
+ }
190
+
191
+ // verify 的信任根:从已安装包重算期望文件集(skill 源 + contracts)与 sha256。
192
+ // 缓存里的 manifest 可被篡改者同步重签,不能作为判定依据。
193
+ function computeExpectedFileHashes(packageRootDir) {
194
+ const expectedFileHashes = {};
195
+ const skillSourceDir = join(packageRootDir, SKILL_SOURCE_SUBPATH);
196
+ for (const relativeFilePath of listFilesRecursively(skillSourceDir)) {
197
+ expectedFileHashes[relativeFilePath] = computeFileHash(
198
+ join(skillSourceDir, relativeFilePath),
199
+ );
200
+ }
201
+ const contractsSourceDir = join(packageRootDir, CONTRACTS_SOURCE_SUBPATH);
202
+ for (const relativeFilePath of listFilesRecursively(contractsSourceDir)) {
203
+ expectedFileHashes[`contracts/${relativeFilePath}`] = computeFileHash(
204
+ join(contractsSourceDir, relativeFilePath),
205
+ );
206
+ }
207
+ return expectedFileHashes;
208
+ }
209
+
210
+ function runVerify(consumerRootDir) {
211
+ const skillCacheDir = join(consumerRootDir, SKILL_RELATIVE_DIR);
212
+ const manifestPath = join(skillCacheDir, MANIFEST_FILE_NAME);
213
+ if (!existsSync(manifestPath)) {
214
+ console.error(`[q1-design] 缺少 manifest: ${manifestPath}`);
215
+ process.exit(1);
216
+ }
217
+
218
+ // manifest 仍读:用于报「版本漂移」与快速定位;判定以安装包重算为准。
219
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
220
+ const problems = [];
221
+
222
+ for (const relativeFilePath of Object.keys(manifest.files)) {
223
+ if (relativeFilePath.split('/').includes('..')) {
224
+ problems.push(`manifest 相对路径非法(含 ..): ${relativeFilePath}`);
225
+ }
226
+ }
227
+
228
+ const packageRootDir = resolveInstalledPackageRoot(consumerRootDir);
229
+ if (!packageRootDir) {
230
+ console.error(
231
+ `[q1-design] 未安装 ${PACKAGE_NAME},verify 以安装包为信任根,无法校验;请先 npm install ${PACKAGE_NAME}`,
232
+ );
233
+ process.exit(1);
234
+ }
235
+
236
+ const expectedFileHashes = computeExpectedFileHashes(packageRootDir);
237
+ for (const [relativeFilePath, expectedHash] of Object.entries(
238
+ expectedFileHashes,
239
+ )) {
240
+ const filePath = join(skillCacheDir, relativeFilePath);
241
+ if (!existsSync(filePath)) {
242
+ problems.push(`缺文件: ${relativeFilePath}`);
243
+ continue;
244
+ }
245
+ const actualHash = computeFileHash(filePath);
246
+ if (actualHash !== expectedHash) {
247
+ problems.push(`与安装包不一致(疑似手改): ${relativeFilePath}`);
248
+ }
249
+ }
250
+
251
+ const expectedFileNames = new Set(Object.keys(expectedFileHashes));
252
+ for (const relativeFilePath of listFilesRecursively(skillCacheDir)) {
253
+ if (
254
+ relativeFilePath !== MANIFEST_FILE_NAME &&
255
+ !expectedFileNames.has(relativeFilePath)
256
+ ) {
257
+ problems.push(`多出未登记文件: ${relativeFilePath}`);
258
+ }
259
+ }
260
+
261
+ const installedVersion = readInstalledPackageVersion(packageRootDir);
262
+ if (installedVersion !== manifest.version) {
263
+ problems.push(
264
+ `版本漂移: manifest=${manifest.version} 已安装=${installedVersion}`,
265
+ );
266
+ }
267
+
268
+ if (problems.length > 0) {
269
+ console.error('[q1-design] verify ✗');
270
+ for (const problem of problems) {
271
+ console.error(` - ${problem}`);
272
+ }
273
+ process.exit(1);
274
+ }
275
+
276
+ console.log('[q1-design] verify ✓');
277
+ }
278
+
279
+ function walkForLint(dirPath, targetFiles) {
280
+ for (const entryName of readdirSync(dirPath).sort()) {
281
+ const entryPath = join(dirPath, entryName);
282
+ if (statSync(entryPath).isDirectory()) {
283
+ walkForLint(entryPath, targetFiles);
284
+ } else if (LINT_EXTENSIONS.has(extname(entryPath))) {
285
+ targetFiles.push(entryPath);
286
+ }
287
+ }
288
+ }
289
+
290
+ function collectLintTargetFiles(inputPaths, baseDir) {
291
+ const targetFiles = [];
292
+ for (const inputPath of inputPaths) {
293
+ const absolutePath = resolve(baseDir, inputPath);
294
+ if (!existsSync(absolutePath)) {
295
+ console.error(`[q1-design] 路径不存在: ${inputPath}`);
296
+ process.exit(1);
297
+ }
298
+ if (statSync(absolutePath).isDirectory()) {
299
+ walkForLint(absolutePath, targetFiles);
300
+ } else if (LINT_EXTENSIONS.has(extname(absolutePath))) {
301
+ targetFiles.push(absolutePath);
302
+ }
303
+ }
304
+ return targetFiles;
305
+ }
306
+
307
+ function isTokensPath(absolutePath, baseDir) {
308
+ return toPosixPath(relative(baseDir, absolutePath)).includes('tokens');
309
+ }
310
+
311
+ function hasDisablePragma(fileContent, ruleId) {
312
+ return new RegExp(`q1-lint-disable\\s+${ruleId}\\b`).test(fileContent);
313
+ }
314
+
315
+ function truncateLine(lineContent, maxLength = 100) {
316
+ const trimmed = lineContent.trim();
317
+ return trimmed.length > maxLength
318
+ ? `${trimmed.slice(0, maxLength)}…`
319
+ : trimmed;
320
+ }
321
+
322
+ function collectLintHits(inputPaths, baseDir) {
323
+ const targetFiles = collectLintTargetFiles(inputPaths, baseDir);
324
+ const hits = [];
325
+
326
+ for (const absoluteFilePath of targetFiles) {
327
+ const fileContent = readFileSync(absoluteFilePath, 'utf8');
328
+ const relativeFilePath = toPosixPath(relative(baseDir, absoluteFilePath));
329
+ const isCssFile = extname(absoluteFilePath) === '.css';
330
+ const q1ds501Applicable =
331
+ isCssFile &&
332
+ !isTokensPath(absoluteFilePath, baseDir) &&
333
+ !hasDisablePragma(fileContent, 'Q1-DS-501');
334
+
335
+ fileContent.split('\n').forEach((lineContent, index) => {
336
+ const lineNumber = index + 1;
337
+ if (DASH_DS_PATTERN.test(lineContent)) {
338
+ hits.push({
339
+ ruleId: 'Q1-DS-001',
340
+ filePath: relativeFilePath,
341
+ lineNumber,
342
+ lineContent,
343
+ });
344
+ }
345
+ if (TRANSITION_ALL_PATTERN.test(lineContent)) {
346
+ hits.push({
347
+ ruleId: 'Q1-DS-401',
348
+ filePath: relativeFilePath,
349
+ lineNumber,
350
+ lineContent,
351
+ });
352
+ }
353
+ if (
354
+ q1ds501Applicable &&
355
+ (HEX_COLOR_PATTERN.test(lineContent) ||
356
+ COLOR_FUNCTION_PATTERN.test(lineContent))
357
+ ) {
358
+ hits.push({
359
+ ruleId: 'Q1-DS-501',
360
+ filePath: relativeFilePath,
361
+ lineNumber,
362
+ lineContent,
363
+ });
364
+ }
365
+ });
366
+ }
367
+
368
+ return hits;
369
+ }
370
+
371
+ function runLint(inputPaths, consumerRootDir) {
372
+ const hits = collectLintHits(inputPaths, consumerRootDir);
373
+
374
+ if (hits.length === 0) {
375
+ console.log('[q1-design] lint ✓');
376
+ return;
377
+ }
378
+
379
+ for (const hit of hits) {
380
+ console.log(
381
+ `${hit.ruleId} ${hit.filePath}:${hit.lineNumber} ${truncateLine(hit.lineContent)}`,
382
+ );
383
+ }
384
+ console.log(`[q1-design] 共 ${hits.length} 处命中`);
385
+ process.exit(1);
386
+ }
387
+
388
+ function resolvePackageRootDir() {
389
+ const scriptDir = dirname(fileURLToPath(import.meta.url));
390
+ return join(scriptDir, '..');
391
+ }
392
+
393
+ function runEval() {
394
+ const packageRootDir = resolvePackageRootDir();
395
+ const evalsDir = join(packageRootDir, 'tooling', 'scripts', 'evals');
396
+ const fixturesIndex = JSON.parse(
397
+ readFileSync(join(evalsDir, 'fixtures.json'), 'utf8'),
398
+ );
399
+
400
+ let hasFailure = false;
401
+ let passCount = 0;
402
+
403
+ for (const fixture of fixturesIndex.fixtures) {
404
+ const fixtureDir = join(evalsDir, fixture.dir);
405
+ const beforeHitCount = collectLintHits(
406
+ [join(fixtureDir, 'before')],
407
+ evalsDir,
408
+ ).filter((hit) => hit.ruleId === fixture.rule).length;
409
+ const afterHitCount = collectLintHits(
410
+ [join(fixtureDir, 'after')],
411
+ evalsDir,
412
+ ).filter((hit) => hit.ruleId === fixture.rule).length;
413
+
414
+ if (
415
+ beforeHitCount === fixture.expectedHits &&
416
+ afterHitCount === 0
417
+ ) {
418
+ console.log(`PASS ${fixture.id}`);
419
+ passCount += 1;
420
+ } else if (beforeHitCount !== fixture.expectedHits) {
421
+ console.log(
422
+ `FAIL ${fixture.id}: before 命中 ${fixture.rule} ${beforeHitCount} 处,期望 ${fixture.expectedHits} 处`,
423
+ );
424
+ hasFailure = true;
425
+ } else {
426
+ console.log(
427
+ `FAIL ${fixture.id}: after 仍命中 ${fixture.rule}(${afterHitCount} 处)`,
428
+ );
429
+ hasFailure = true;
430
+ }
431
+ }
432
+
433
+ console.log(
434
+ `[q1-design] eval ${passCount}/${fixturesIndex.fixtures.length} PASS`,
435
+ );
436
+ if (hasFailure) {
437
+ process.exit(1);
438
+ }
439
+ }
440
+
441
+ function printUsage() {
442
+ console.error('Usage: q1-design <sync|verify|lint|eval>');
443
+ console.error(
444
+ ' lint [路径...] 扫描 .css/.js/.jsx/.ts/.tsx/.mjs,默认 src,目录递归',
445
+ );
446
+ console.error(' eval 对 tooling/scripts/evals 的 fixtures 跑 before/after 断言');
447
+ }
448
+
449
+ const subcommand = process.argv[2];
450
+ const consumerRootDir = process.cwd();
451
+
452
+ if (subcommand === 'sync') {
453
+ runSync(consumerRootDir);
454
+ } else if (subcommand === 'verify') {
455
+ runVerify(consumerRootDir);
456
+ } else if (subcommand === 'lint') {
457
+ const inputPaths = process.argv.slice(3);
458
+ runLint(inputPaths.length > 0 ? inputPaths : ['src'], consumerRootDir);
459
+ } else if (subcommand === 'eval') {
460
+ runEval();
461
+ } else {
462
+ printUsage();
463
+ process.exit(1);
464
+ }
@@ -0,0 +1,39 @@
1
+ # q1travel Design Engineering Principles
2
+
3
+ These principles define how q1travel design-system work is evaluated. They are product and engineering standards, not decorative preferences.
4
+
5
+ ## Usefulness
6
+
7
+ - Solve real user and team problems before polishing surface details.
8
+ - Make frequent tasks feel direct, predictable, and low-friction.
9
+ - Treat documentation as a product surface: every page should answer what the element is, when to use it, and what contract it carries.
10
+
11
+ ## Whole Experience
12
+
13
+ - Own copy, layout, interaction, states, accessibility, performance, and implementation together.
14
+ - Design empty, sparse, dense, loading, error, hover, active, focus, and disabled states before calling a component complete.
15
+ - Keep the public standard neutral; do not bind the system to one internal workflow or one business line.
16
+
17
+ ## Constraints
18
+
19
+ - Understand the user, route, device, theme, and implementation boundary before choosing a solution.
20
+ - Prefer platform semantics and CSS layout before measurement scripts or screenshot-driven constants.
21
+ - Use q1travel tokens and canonical components as the source of production behavior.
22
+
23
+ ## Inclusion
24
+
25
+ - Build keyboard-operable, readable, and resilient interfaces by default.
26
+ - Make complexity available without making it required.
27
+ - Preserve visible focus, accessible names, usable hit targets, and non-color-only status cues.
28
+
29
+ ## Craft
30
+
31
+ - Scope small enough to execute well.
32
+ - Align every element deliberately to a grid, edge, baseline, or optical center.
33
+ - Increase contrast on interaction states and avoid animation that does not clarify cause and effect.
34
+
35
+ ## Team Defaults
36
+
37
+ - Convert repeated review feedback into contracts, shared components, or checks.
38
+ - Keep changes reviewable, evidence-backed, and easy to roll forward.
39
+ - If implementation and contract drift, fix the source contract or stop and ask for direction.