diff --git a/apps/desktop/src/main/__tests__/spacing-4pt-ratchet-contract.test.ts b/apps/desktop/src/main/__tests__/spacing-4pt-ratchet-contract.test.ts new file mode 100644 index 0000000000..2f6749b309 --- /dev/null +++ b/apps/desktop/src/main/__tests__/spacing-4pt-ratchet-contract.test.ts @@ -0,0 +1,114 @@ +/** + * P-4PT spacing ratchet (design-refinement-roadmap-2026-07 §1.4, owner + * decision D1: converge on a 4pt spacing grid, migrating incrementally). + * + * Every padding/gap/margin px value in renderer CSS should be a multiple + * of 4 (0 allowed; 1px and 2px exempt as hairline/optical nudges). The + * legacy drift (442 values at baseline) is FROZEN per file below and may + * only go DOWN: + * + * - touching a file and reducing its count → update the baseline DOWN + * - adding a new off-grid value anywhere → this test fails + * - new CSS files must be born clean (no entry = zero tolerance) + * + * This is a ratchet, not an allowlist of specific lines, so refactors + * inside a file stay cheap while the global trend is monotonic. + */ + +import { strict as assert } from 'node:assert'; +import { readdir, readFile } from 'node:fs/promises'; +import { join, relative, resolve } from 'node:path'; +import { describe, it } from 'node:test'; + +const DESKTOP_ROOT = process.cwd().endsWith(join('apps', 'desktop')) + ? process.cwd() + : resolve(process.cwd(), 'apps', 'desktop'); +const RENDERER_ROOT = resolve(DESKTOP_ROOT, 'src', 'renderer'); + +/** Frozen per-file baseline (2026-07-03). Only decrease these numbers. */ +const BASELINE: ReadonlyMap = new Map([ + ['src/renderer/maka-tokens.css', 23], + ['src/renderer/styles/chat-header.css', 26], + ['src/renderer/styles/chat-message.css', 4], + ['src/renderer/styles/composer.css', 12], + ['src/renderer/styles/daily-review.css', 17], + ['src/renderer/styles/health-center.css', 11], + ['src/renderer/styles/module-pages.css', 76], + ['src/renderer/styles/onboarding.css', 29], + ['src/renderer/styles/permission-center.css', 28], + ['src/renderer/styles/reasoning-panel.css', 3], + ['src/renderer/styles/settings/bot.css', 23], + ['src/renderer/styles/settings/connection.css', 9], + ['src/renderer/styles/settings/form.css', 7], + ['src/renderer/styles/settings/models.css', 31], + ['src/renderer/styles/settings/nav-sidebar.css', 21], + ['src/renderer/styles/settings/provider-editor.css', 20], + ['src/renderer/styles/settings/theme-preview.css', 23], + ['src/renderer/styles/sidebar.css', 36], + ['src/renderer/styles/tool-output.css', 8], + ['src/renderer/styles/tool-stream.css', 35], +]); + +const DECL_RE = + /(?:^|;|\{)\s*(padding|gap|margin|row-gap|column-gap|padding-(?:top|right|bottom|left|inline|block)|margin-(?:top|right|bottom|left|inline|block))\s*:\s*([^;}]+)/gm; +const PX_RE = /(? { + const out: string[] = []; + for (const entry of await readdir(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) out.push(...(await collectCssFiles(full))); + else if (entry.name.endsWith('.css')) out.push(full); + } + return out; +} + +describe('P-4PT spacing ratchet', () => { + it('padding/gap/margin px values stay on the 4pt grid (frozen legacy may only shrink)', async () => { + const files = await collectCssFiles(RENDERER_ROOT); + const failures: string[] = []; + for (const file of files.sort()) { + const rel = relative(DESKTOP_ROOT, file).split('\\').join('/'); + const count = countOffGrid(await readFile(file, 'utf8')); + const allowed = BASELINE.get(rel) ?? 0; + if (count > allowed) { + failures.push(`${rel}: ${count} off-grid spacing values (baseline ${allowed})`); + } + } + assert.deepEqual( + failures, + [], + `off-grid spacing crept in — use 4/8/12/16/24/32 (1px/2px hairlines exempt), or shrink the file below its frozen baseline:\n${failures.join('\n')}`, + ); + }); + + it('baseline entries stay honest (no stale higher-than-actual counts)', async () => { + // Guard against the ratchet rusting: if a file improves but the + // baseline is not updated, the slack could hide future regressions. + // Tolerate up to 3 slack per file before requiring a baseline update. + const stale: string[] = []; + for (const [rel, allowed] of BASELINE) { + const count = countOffGrid(await readFile(resolve(DESKTOP_ROOT, rel), 'utf8')); + if (allowed - count > 3) { + stale.push(`${rel}: baseline ${allowed} but actual ${count} — lower the baseline`); + } + } + assert.deepEqual(stale, [], stale.join('\n')); + }); +}); diff --git a/apps/desktop/src/renderer/maka-tokens.css b/apps/desktop/src/renderer/maka-tokens.css index 5fc4efd325..c1726b3855 100644 --- a/apps/desktop/src/renderer/maka-tokens.css +++ b/apps/desktop/src/renderer/maka-tokens.css @@ -126,8 +126,11 @@ --foreground-5: color-mix(in oklch, var(--foreground) 5%, var(--background)); --foreground-8: color-mix(in oklch, var(--foreground) 8%, var(--background)); --foreground-10: color-mix(in oklch, var(--foreground) 10%, var(--background)); - --foreground-20: color-mix(in oklch, var(--foreground) 20%, var(--background)); - --foreground-30: color-mix(in oklch, var(--foreground) 30%, var(--background)); + /* P-TEXT (roadmap §1.6): the -20/-30 text tiers were orphans (8 call + sites total) sitting between the surface washes (2..10) and the + real text ladder (40/50/60/70/80). Text uses moved to -40 (also a + contrast fix: 30% ink fails 4.5:1); decorative uses inlined their + color-mix. Fewer tiers = crisper hierarchy. */ --foreground-40: color-mix(in oklch, var(--foreground) 40%, var(--background)); --foreground-50: color-mix(in oklch, var(--foreground) 50%, var(--background)); --foreground-60: color-mix(in oklch, var(--foreground) 60%, var(--background)); @@ -681,8 +684,6 @@ --color-foreground-5: var(--foreground-5); --color-foreground-8: var(--foreground-8); --color-foreground-10: var(--foreground-10); - --color-foreground-20: var(--foreground-20); - --color-foreground-30: var(--foreground-30); --color-foreground-40: var(--foreground-40); --color-foreground-50: var(--foreground-50); --color-foreground-60: var(--foreground-60); diff --git a/apps/desktop/src/renderer/styles/onboarding.css b/apps/desktop/src/renderer/styles/onboarding.css index 2a9469b317..5806e9bf9f 100644 --- a/apps/desktop/src/renderer/styles/onboarding.css +++ b/apps/desktop/src/renderer/styles/onboarding.css @@ -863,7 +863,7 @@ } .maka-list-group-count { font-variant-numeric: tabular-nums; - color: var(--foreground-30); + color: var(--foreground-40); font-size: var(--font-size-caption); font-weight: 500; } diff --git a/apps/desktop/src/renderer/styles/settings/bot.css b/apps/desktop/src/renderer/styles/settings/bot.css index 8dd58cda2d..00ac4db9a0 100644 --- a/apps/desktop/src/renderer/styles/settings/bot.css +++ b/apps/desktop/src/renderer/styles/settings/bot.css @@ -114,7 +114,7 @@ width: 9px; height: 9px; border-radius: 50%; - background: var(--foreground-30); + background: color-mix(in oklch, var(--foreground) 30%, var(--background)); box-shadow: 0 0 0 2px var(--background); } .settingsBotLogo[data-large="true"] .settingsBotLogoStatusDot { diff --git a/apps/desktop/src/renderer/styles/sidebar.css b/apps/desktop/src/renderer/styles/sidebar.css index 00b490425d..9a4cfbe85e 100644 --- a/apps/desktop/src/renderer/styles/sidebar.css +++ b/apps/desktop/src/renderer/styles/sidebar.css @@ -165,7 +165,7 @@ .maka-resize-handle:hover::after, .isResizingColumns .maka-resize-handle::after { - background: var(--foreground-20); + background: color-mix(in oklch, var(--foreground) 20%, var(--background)); } /* Keyboard-focused separator: replace the suppressed native outline with a diff --git a/apps/desktop/src/renderer/styles/tool-output.css b/apps/desktop/src/renderer/styles/tool-output.css index 3902d8be51..3634bfc315 100644 --- a/apps/desktop/src/renderer/styles/tool-output.css +++ b/apps/desktop/src/renderer/styles/tool-output.css @@ -376,7 +376,7 @@ .maka-composer-context-plus { width: 26px; height: 26px; - border: 1px solid var(--foreground-20); + border: 1px solid color-mix(in oklch, var(--foreground) 20%, var(--background)); border-radius: var(--radius-pill); background: transparent; } diff --git a/docs/design-refinement-roadmap-2026-07.md b/docs/design-refinement-roadmap-2026-07.md index 48e19dd003..cef8a0a081 100644 --- a/docs/design-refinement-roadmap-2026-07.md +++ b/docs/design-refinement-roadmap-2026-07.md @@ -112,8 +112,12 @@ 4. **P-MOTION** ✅(已达标,无需动作):duration token 120/150/180/280ms 完全符合 Emil 表且取下限;装饰性入场动画已被 #406 gap 3 清除;剩余 keyframes 全部是功能性流式动画(D3 豁免) -5. **P-TEXT**:四档文字语义别名 + 新代码治理契约 -6. **P-4PT**:4pt 治理契约(新增声明检查 + 存量 allowlist) +5. **P-TEXT** ✅(首轮):孤儿档 -20/-30 清除(8 处调用点:文字并入 + -40 兼修对比度、装饰内联 color-mix);文字主力收敛为 + 40/50/60/70/80。全量四档语义别名迁移留待逐面触碰。 +6. **P-4PT** ✅:ratchet 契约上线 + (spacing-4pt-ratchet-contract.test.ts):442 个存量违例按文件冻结 + 只减不增,新文件零容忍,防 baseline 生锈的 slack 检查 7. **P-DARK**:dark elevation 独立化(依赖 P-SHADOW) 8. **P-STATE**:状态全周期补齐审计(skeleton 对形/empty 构图/inline error) diff --git a/packages/ui/src/primitives/chat.tsx b/packages/ui/src/primitives/chat.tsx index d0ddd28892..02eab37936 100644 --- a/packages/ui/src/primitives/chat.tsx +++ b/packages/ui/src/primitives/chat.tsx @@ -449,13 +449,13 @@ const toolVariants = cva("", { // `.maka-tool-status-dot` (+ the `[data-status]` color swaps; running adds // the box-shadow ring + `maka-tool-pulse` breath — keyframe stays in CSS). dot: - "w-[8px] h-[8px] rounded-[var(--radius-pill)] bg-[var(--foreground-30)] [flex:0_0_auto]" + "w-[8px] h-[8px] rounded-[var(--radius-pill)] bg-[var(--foreground-40)] [flex:0_0_auto]" // `waiting_permission` dot tint — see `WP_DOT_BG` above (String.raw). + " " + WP_DOT_BG + " data-[status=running]:bg-[var(--status-running)] data-[status=running]:[box-shadow:0_0_0_3px_oklch(from_var(--status-running)_l_c_h_/_0.15)] data-[status=running]:[animation:maka-tool-pulse_1.5s_ease-in-out_infinite]" + " data-[status=completed]:bg-[var(--success)]" + " data-[status=errored]:bg-[var(--destructive)]" - + " data-[status=interrupted]:bg-[var(--foreground-30)]", + + " data-[status=interrupted]:bg-[var(--foreground-40)]", // `.maka-tool-name` — the mono tool name, ellipsized. name: "min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-[color:var(--foreground)] font-medium [font-family:var(--font-mono)]", diff --git a/packages/ui/stories/design-tokens.stories.tsx b/packages/ui/stories/design-tokens.stories.tsx index 06451c5189..069ae0a97a 100644 --- a/packages/ui/stories/design-tokens.stories.tsx +++ b/packages/ui/stories/design-tokens.stories.tsx @@ -35,7 +35,7 @@ const foregroundScale = [ ['foreground-2', '--foreground-2'], ['foreground-5', '--foreground-5'], ['foreground-10', '--foreground-10'], - ['foreground-20', '--foreground-20'], + ['foreground-50', '--foreground-50'], ['foreground-40', '--foreground-40'], ['foreground-60', '--foreground-60'], ['foreground-80', '--foreground-80'],