Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 9 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,12 @@ packages/browser-control-runtime/src/_generated/** linguist-generated=true
apps/mobile/scripts/release-*.mjs export-ignore
apps/mobile/scripts/release-*.sh export-ignore
apps/mobile/scripts/promote-*.mjs export-ignore

# DTCG generated whole files / marked regions: stable bytes on Windows as well.
apps/desktop/src/renderer/themes/colors.ts text eol=lf
apps/desktop/src/renderer/themes/builtin/*.ts text eol=lf
apps/desktop/src/renderer/styles/generated/* text eol=lf
apps/desktop/src/shared/generated/appearance-tokens.ts text eol=lf
apps/desktop/src/shared/windowBackdrop.ts text eol=lf
apps/desktop/tailwind.config.ts text eol=lf
docs/design-rules/DESIGN.md text eol=lf
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import config from '../../../tailwind.config';

// Independent pre-DS-8 expectations (4f03ea9a7b): Tailwind 3 defaults,
// globals.css motion values, and Input's approved 40px / 12px geometry.
// Never generate these expectations from DTCG. Approved style changes must
// explicitly update this ledger under design-governance §1.1 and §6.
const SPACING = {
'0': '0px', '1': '0.25rem', '2': '0.5rem', '3': '0.75rem',
'4': '1rem', '5': '1.25rem', '6': '1.5rem', '7': '1.75rem',
'8': '2rem', '9': '2.25rem', '10': '2.5rem', '11': '2.75rem',
'12': '3rem', '14': '3.5rem', '16': '4rem', '20': '5rem',
'24': '6rem', '28': '7rem', '32': '8rem', '36': '9rem',
'40': '10rem', '44': '11rem', '48': '12rem', '52': '13rem',
'56': '14rem', '60': '15rem', '64': '16rem', '72': '18rem',
'80': '20rem', '96': '24rem', px: '1px',
'0.5': '0.125rem', '1.5': '0.375rem', '2.5': '0.625rem', '3.5': '0.875rem',
};
const RADIUS = {
none: '0px', DEFAULT: '0.25rem', xl: '0.75rem',
'2xl': '1rem', '3xl': '1.5rem', full: '9999px',
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
};
const MOTION = {
instant: '80ms', fast: '150ms', base: '200ms', enter: '250ms', exit: '150ms',
'spinner-cycle': '1000ms', 'sidebar-title-marquee-per-viewport': '2400ms',
'ease-out': 'cubic-bezier(0.16, 1, 0.3, 1)',
'ease-in': 'cubic-bezier(0.4, 0, 1, 1)',
'ease-move': 'cubic-bezier(0.4, 0, 0.2, 1)',
};
const css = readFileSync(new URL('../styles/generated/tokens.css', import.meta.url), 'utf8');
const variables = Object.fromEntries(
[...css.matchAll(/--([\w-]+):\s*([^;]+);/g)].map((match) => [match[1], match[2]]),
);
const theme = config.theme!.extend!;

function resolveVariables(value: string): string {
return value.replace(/var\(--([\w-]+)(?:,\s*[^)]+)?\)/g, (expression, name: string) => {
// The user radius must stay symbolic; its default/null fallback is covered
// by registry freezes and theme compatibility tests, not a fixed CSS value.
if (name === 'radius') return expression;
expect(variables[name], `Missing CSS variable --${name}`).toBeDefined();
return variables[name];
});
}

function resolveMap(map: unknown) {
return Object.fromEntries(
Object.entries(map as Record<string, string>).map(([key, value]) => [key, resolveVariables(value)]),
);
}

describe('DS-8 foundation defaults freeze', () => {
it('keeps all 35 spacing classes, six fixed radii and three UI weights at approved values', () => {
expect(resolveMap(theme.spacing)).toEqual(SPACING);
expect(resolveMap(theme.borderRadius)).toEqual(RADIUS);
expect(resolveMap(theme.fontWeight)).toEqual({ normal: '400', medium: '500', semibold: '600' });
});

it('keeps user radius derivation and standard Input geometry', () => {
expect(theme.borderRadius).toMatchObject({
lg: 'var(--radius)',
md: 'calc(var(--radius) - var(--radius-offset-md))',
sm: 'calc(var(--radius) - var(--radius-offset-sm))',
});
expect(variables).toMatchObject({
'radius-offset-md': '2px', 'radius-offset-sm': '4px',
'size-input-lg': '40px', 'space-input-lg': '12px',
});
});

it('keeps all motion durations/curves and the real Tailwind transition defaults', () => {
expect(Object.fromEntries(
Object.entries(variables)
.filter(([key]) => key.startsWith('motion-'))
.map(([key, value]) => [key.slice('motion-'.length), value]),
)).toEqual(MOTION);
expect(theme.transitionDuration).toEqual({ DEFAULT: 'var(--motion-fast, 150ms)' });
expect(theme.transitionTimingFunction).toEqual({
DEFAULT: 'var(--motion-ease-move, cubic-bezier(0.4, 0, 0.2, 1))',
});
});
});
38 changes: 19 additions & 19 deletions apps/desktop/src/renderer/__tests__/markdownDiffBlock.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { colorRegistry } from '../themes/color-registry';
import '../themes/colors';
/**
* markdownDiffBlock.test.ts
* ---------------------------------------------------------------------------
Expand Down Expand Up @@ -53,39 +55,37 @@ const diffBlockSrc = readFileSync(diffBlockPath, 'utf8');
const colorsSrc = readFileSync(colorsPath, 'utf8');
const globalsSrc = readFileSync(globalsPath, 'utf8');

function registeredColorBlock(name: string): string {
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const match = colorsSrc.match(new RegExp(`registerColor\\('${escaped}'[\\s\\S]*?\\n\\}, '[^']+'\\);`));
expect(match, `registerColor('${name}') not found`).toBeTruthy();
if (!match) throw new Error(`registerColor('${name}') not found`);
return match[0];
function registeredColorBlock(name: string) {
const entry = colorRegistry.getColors().find(color => color.id === name);
expect(entry, `registerColor('${name}') not found`).toBeTruthy();
return entry!.defaults;
}

describe('F1 — theme tokens use GitHub-standard diff foregrounds', () => {
it(':root light --diff-add-fg = #22863a', () => {
expect(registeredColorBlock('diff-add-fg')).toMatch(/light:\s*'#22863a'/);
expect(registeredColorBlock('diff-add-fg')).toMatchObject({ light: '#22863a' });
});

it(':root light --diff-del-fg = #b31d28', () => {
expect(registeredColorBlock('diff-del-fg')).toMatch(/light:\s*'#b31d28'/);
expect(registeredColorBlock('diff-del-fg')).toMatchObject({ light: '#b31d28' });
});

it('.dark --diff-add-fg = #7ee787', () => {
expect(registeredColorBlock('diff-add-fg')).toMatch(/dark:\s*'#7ee787'/);
expect(registeredColorBlock('diff-add-fg')).toMatchObject({ dark: '#7ee787' });
});

it('.dark --diff-del-fg = #ff7b72', () => {
expect(registeredColorBlock('diff-del-fg')).toMatch(/dark:\s*'#ff7b72'/);
expect(registeredColorBlock('diff-del-fg')).toMatchObject({ dark: '#ff7b72' });
});

it('backgrounds use GitHub-standard red/green for full-row fill', () => {
// diff 颜色现在由主题 token 注册,组件只消费 var(--diff-*)。
expect(registeredColorBlock('diff-add-bg')).toMatch(/light:\s*'#f0fff4'/);
expect(registeredColorBlock('diff-del-bg')).toMatch(/light:\s*'#ffeef0'/);
expect(registeredColorBlock('diff-add-bg')).toMatch(/dark:\s*'#033a16'/);
expect(registeredColorBlock('diff-del-bg')).toMatch(/dark:\s*'#67060c'/);
expect(registeredColorBlock('diff-line-num')).toMatch(/light:\s*'var\(--text-tertiary-stone\)'/);
expect(registeredColorBlock('diff-line-num')).toMatch(/dark:\s*'var\(--text-tertiary-stone\)'/);
expect(registeredColorBlock('diff-add-bg')).toMatchObject({ light: '#f0fff4' });
expect(registeredColorBlock('diff-del-bg')).toMatchObject({ light: '#ffeef0' });
expect(registeredColorBlock('diff-add-bg')).toMatchObject({ dark: '#033a16' });
expect(registeredColorBlock('diff-del-bg')).toMatchObject({ dark: '#67060c' });
expect(registeredColorBlock('diff-line-num')).toMatchObject({ light: 'var(--text-tertiary-stone)' });
expect(registeredColorBlock('diff-line-num')).toMatchObject({ dark: 'var(--text-tertiary-stone)' });
});

it('hljs-addition / hljs-deletion are display:block so backgrounds reach the right edge', () => {
Expand All @@ -103,10 +103,10 @@ describe('F1 — theme tokens use GitHub-standard diff foregrounds', () => {

// The old light-mode add was #262626 (Near Black) and dark-mode add was
// #d4d4d4 (Soft Gray). Those must no longer be tied to --diff-add-fg.
expect(addFgBlock).not.toMatch(/#262626/);
expect(addFgBlock).not.toMatch(/#d4d4d4/);
expect(Object.values(addFgBlock)).not.toContain('#262626');
expect(Object.values(addFgBlock)).not.toContain('#d4d4d4');
// Old del was Stone #737373 in both modes.
expect(delFgBlock).not.toMatch(/#737373/);
expect(Object.values(delFgBlock)).not.toContain('#737373');
});
});

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { colorRegistry } from '../themes/color-registry';
import '../themes/colors';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';

Expand Down Expand Up @@ -289,29 +291,29 @@ describe('NewMakerDraftRoute CREATE AGENT visual contract', () => {
expect(modelSelectorSource).not.toContain('max-w-[180px] truncate');

expect(colorsSource).toContain("'create-agent-send-bg'");
expect(colorsSource).toContain("light: '#3C3F43'");
expect(colorsSource).toContain("dark: '#EEEEEE'");
expect(colorRegistry.resolveDefault('create-agent-send-bg', 'light')).toBe('#3C3F43');
expect(colorRegistry.resolveDefault('create-agent-send-bg', 'dark')).toBe('#EEEEEE');
expect(colorsSource).toContain("'create-agent-send-icon'");
expect(colorsSource).toContain("light: '#FCFCFC'");
expect(colorRegistry.resolveDefault('create-agent-send-icon', 'light')).toBe('#FCFCFC');
expect(colorsSource).not.toContain("'create-agent-send-border'");
expect(colorsSource).toContain("'create-agent-send-bg-hover'");
expect(colorsSource).toContain("light: '#2E3237'");
expect(colorsSource).toContain("dark: '#E2E2E2'");
expect(colorRegistry.resolveDefault('create-agent-send-bg-hover', 'light')).toBe('#2E3237');
expect(colorRegistry.resolveDefault('create-agent-send-bg-hover', 'dark')).toBe('#E2E2E2');
expect(colorsSource).toContain("'create-agent-send-bg-pressed'");
expect(colorsSource).toContain("light: '#25282C'");
expect(colorsSource).toContain("dark: '#D4D4D4'");
expect(colorRegistry.resolveDefault('create-agent-send-bg-pressed', 'light')).toBe('#25282C');
expect(colorRegistry.resolveDefault('create-agent-send-bg-pressed', 'dark')).toBe('#D4D4D4');
expect(colorsSource).toContain("'create-agent-send-disabled-bg'");
expect(colorsSource).toContain("dark: '#444242'");
expect(colorRegistry.resolveDefault('create-agent-send-disabled-bg', 'dark')).toBe('#444242');
expect(colorsSource).toContain("'create-agent-send-disabled-icon'");
expect(colorsSource).toContain("dark: '#585555'");
expect(colorRegistry.resolveDefault('create-agent-send-disabled-icon', 'dark')).toBe('#585555');
expect(colorsSource).toContain("'create-agent-segment-inactive-text'");
expect(colorsSource).toContain("light: '#9A9DA3'");
expect(colorsSource).toContain("dark: '#6F6F6F'");
expect(colorRegistry.resolveDefault('create-agent-segment-inactive-text', 'light')).toBe('#9A9DA3');
expect(colorRegistry.resolveDefault('create-agent-segment-inactive-text', 'dark')).toBe('#6F6F6F');
expect(colorsSource).toContain("'create-agent-control-border'");
expect(colorsSource).toContain("light: '#DCDFE3'");
expect(colorsSource).toContain("dark: '#434343'");
expect(colorRegistry.resolveDefault('create-agent-control-border', 'light')).toBe('#DCDFE3');
expect(colorRegistry.resolveDefault('create-agent-control-border', 'dark')).toBe('#434343');
expect(colorsSource).toContain("'create-agent-control-icon'");
expect(colorsSource).toContain("light: '#3C3F43'");
expect(colorRegistry.resolveDefault('create-agent-control-icon', 'light')).toBe('#3C3F43');

expect(chatInputSource).toContain(
"'min-w-0 flex-nowrap justify-between gap-2 overflow-hidden'",
Expand Down Expand Up @@ -443,11 +445,11 @@ describe('NewMakerDraftRoute CREATE AGENT visual contract', () => {
);

expect(colorsSource).toContain("'send-btn-bg'");
expect(colorsSource).toContain("light: '#3C3F43'");
expect(colorsSource).toContain("dark: '#EEEEEE'");
expect(colorRegistry.resolveDefault('send-btn-bg', 'light')).toBe('var(--accent-cta-bg)');
expect(colorRegistry.resolveDefault('send-btn-bg', 'dark')).toBe('var(--accent-cta-bg)');
expect(colorsSource).toContain("'send-btn-icon'");
expect(colorsSource).toContain("light: '#FCFCFC'");
expect(colorsSource).toContain("dark: '#252222'");
expect(colorRegistry.resolveDefault('send-btn-icon', 'light')).toBe('var(--surface-on-card)');
expect(colorRegistry.resolveDefault('send-btn-icon', 'dark')).toBe('var(--surface-on-card)');
expect(colorsSource).not.toContain("'stop-btn-bg'");
expect(colorsSource).not.toContain("'stop-btn-icon'");
});
Expand Down
6 changes: 3 additions & 3 deletions apps/desktop/src/renderer/__tests__/textLightbox.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { colorRegistry } from '../themes/color-registry';
import '../themes/colors';
/**
* textLightbox.test.ts
* ---------------------------------------------------------------------------
Expand Down Expand Up @@ -82,9 +84,7 @@ describe('TextLightbox — F3 overlay style', () => {
it('overlay background uses the lightbox overlay theme token', () => {
// overlay 已迁到主题 token,避免组件内写死 rgba 颜色。
expect(source).toContain("background: 'var(--overlay-lightbox)'");
expect(colorsSource).toMatch(
/registerColor\('overlay-lightbox'[\s\S]*light:\s*'rgba\(0, 0, 0, 0\.85\)'[\s\S]*dark:\s*'rgba\(0, 0, 0, 0\.85\)'/,
);
for (const mode of ['light', 'dark'] as const) expect(colorRegistry.resolveDefault('overlay-lightbox', mode)).toBe('rgba(0, 0, 0, 0.85)');
});

it('overlay is rendered via createPortal to document.body', () => {
Expand Down
97 changes: 22 additions & 75 deletions apps/desktop/src/renderer/__tests__/typographyDiscipline.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import config from '../../../tailwind.config';
import { UI_TEXT_TOKEN_SIZES, SCALED_TAILWIND_TOKENS, NUMERIC_TEXT_CLASSES } from '../styles/generated/token-mappings';
import { cn } from '../lib/utils';
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join, relative, sep } from 'node:path';
import { describe, expect, it } from 'vitest';
Expand Down Expand Up @@ -720,75 +723,21 @@ describe('typography discipline (DESIGN.md §3, #1505)', () => {
expect(exemptionDrift).toEqual([]);
});

it('mirrors the size ladder across DESIGN.md and the three authoritative code sources', () => {
it('mirrors the independent size ladder across generated values and actual consumers', () => {
const expected = [...SIZE_WHITELIST].sort((a, b) => a - b);
const expectedSemantic = { xs: 12, sm: 14, base: 16, lg: 18 };

const config = readFileSync(join(ROOT, 'tailwind.config.ts'), 'utf8');
const fontSizeBlock =
/fontSize:\s*\{([\s\S]*?)^\s*\},\s*borderRadius:/m.exec(config)?.[1] ?? '';
const configNumeric = parseNumericConfigMappings(config);
expect(configNumeric).toEqual(expected.map((n) => [n, n]));
const configSemantic = Object.fromEntries(
[...fontSizeBlock.matchAll(/^\s*(xs|sm|base|lg):\s*\[['"]var\(--text-\1\)['"]/gm)].map(
(m) => [m[1], expectedSemantic[m[1] as keyof typeof expectedSemantic]],
),
);
expect(configSemantic).toEqual(expectedSemantic);
const preservedConfigSemantic = Object.fromEntries(
[
...fontSizeBlock.matchAll(
/^\s*['"]?(xl|2xl|3xl|4xl|5xl)['"]?:\s*\[['"]var\(--text-\1\)['"]/gm,
),
].map((m) => [m[1], SEMANTIC_TOKEN_VALUES[m[1] as keyof typeof SEMANTIC_TOKEN_VALUES]]),
);
expect(preservedConfigSemantic).toEqual({ xl: 20, '2xl': 24, '3xl': 30, '4xl': 36, '5xl': 48 });

const css = readFileSync(join(ROOT, 'src/renderer/styles/globals.css'), 'utf8');
const cssNumeric = parseNumericCssMappings(css);
expect(cssNumeric).toEqual(expected.map((n) => [n, n]));
const cssSemantic = Object.fromEntries(
[...css.matchAll(/--text-(xs|sm|base|lg):\s*(\d+)px;/g)].map((m) => [m[1], Number(m[2])]),
);
expect(cssSemantic).toEqual(expectedSemantic);
const preservedCssSemantic = Object.fromEntries(
[...css.matchAll(/--text-(xl|2xl|3xl|4xl|5xl):\s*(\d+)px;/g)].map((m) => [
m[1],
Number(m[2]),
]),
);
expect(preservedCssSemantic).toEqual({ xl: 20, '2xl': 24, '3xl': 30, '4xl': 36, '5xl': 48 });

expect(Object.entries(UI_TEXT_TOKEN_SIZES).map(([key, value]) => [Number(key), value])).toEqual(expected.map(n => [n, n]));
expect(SCALED_TAILWIND_TOKENS).toEqual({ xs: 12, sm: 14, base: 16, lg: 18, xl: 20, '2xl': 24, '3xl': 30, '4xl': 36, '5xl': 48 });
const TAILWIND_FONT_SIZE = config.theme?.extend?.fontSize as Record<string, unknown>;
for (const n of expected) expect(TAILWIND_FONT_SIZE[n]).toBe(`var(--text-${n})`);
for (const key of Object.keys(SCALED_TAILWIND_TOKENS)) expect(TAILWIND_FONT_SIZE[key]).toEqual([`var(--text-${key})`, { lineHeight: `var(--text-${key}-line-height)` }]);
const css = readFileSync(join(ROOT, 'src/renderer/styles/generated/tokens.css'), 'utf8');
expect(parseNumericCssMappings(css)).toEqual(expected.map(n => [n, n]));
const globals = readFileSync(join(ROOT, 'src/renderer/styles/globals.css'), 'utf8');
expect(globals).toContain("@import './generated/tokens.css'");
expect(parseNumericCssMappings(globals)).toEqual([]);
const hook = readFileSync(join(ROOT, 'src/renderer/hooks/useFontSettings.ts'), 'utf8');
const runtimeBlock = /UI_TEXT_TOKEN_SIZES\s*=\s*\[([^\]]+)\]/s.exec(hook)?.[1] ?? '';
const runtimeNumeric = [...runtimeBlock.matchAll(/\b\d+\b/g)]
.map((m) => Number(m[0]))
.sort((a, b) => a - b);
expect(runtimeNumeric).toEqual(expected);
expect(hook).toMatch(
/for \(const tokenSize of UI_TEXT_TOKEN_SIZES\)[\s\S]*?set\(`--text-\$\{tokenSize\}`, `\$\{Math\.round\(tokenSize \* scale\)\}px`\)/,
);
const semanticBlock = /SCALED_TAILWIND_TOKENS\s*=\s*\{([^}]+)\}/s.exec(hook)?.[1] ?? '';
const runtimeSemantic = Object.fromEntries(
[...semanticBlock.matchAll(/^\s*(xs|sm|base|lg):\s*(\d+),/gm)].map((m) => [
m[1],
Number(m[2]),
]),
);
expect(runtimeSemantic).toEqual(expectedSemantic);
const preservedRuntimeSemantic = Object.fromEntries(
[...semanticBlock.matchAll(/^\s*['"]?(xl|2xl|3xl|4xl|5xl)['"]?:\s*(\d+),/gm)].map((m) => [
m[1],
Number(m[2]),
]),
);
expect(preservedRuntimeSemantic).toEqual({
xl: 20,
'2xl': 24,
'3xl': 30,
'4xl': 36,
'5xl': 48,
});
expect(hook).toContain("from '../styles/generated/token-mappings'");
expect(hook).not.toMatch(/UI_TEXT_TOKEN_SIZES\s*=/);

// DESIGN.md §3「桌面 UI 字号白名单」小节的两个 {…} 集合。
const design = readFileSync(DESIGN_MD, 'utf8');
Expand All @@ -805,14 +754,12 @@ describe('typography discipline (DESIGN.md §3, #1505)', () => {
expect(docTiers).toEqual(expected);
});

it('keeps the tailwind-merge font-size consumer in sync', () => {
const expected = [...SIZE_WHITELIST].sort((a, b) => a - b);
const mergeUtils = readFileSync(join(ROOT, 'src/renderer/lib/utils.ts'), 'utf8');
const mergeBlock = /font-size'[\s\S]*?text:\s*\[([^\]]+)\]/.exec(mergeUtils)?.[1] ?? '';
const mergeNumeric = [...mergeBlock.matchAll(/['"](\d+)['"]/g)]
.map((m) => Number(m[1]))
.sort((a, b) => a - b);
expect(mergeNumeric).toEqual(expected);
it('keeps the actual tailwind-merge font-size consumer in sync', () => {
const expected = [...SIZE_WHITELIST];
expect(NUMERIC_TEXT_CLASSES.map(Number)).toEqual(expected);
for (const a of expected) for (const b of expected) {
expect(cn(`text-${a}`, `text-${b}`)).toBe(`text-${b}`);
}
});

// ── 红绿 fixture:检查器本身的行为锚定(防误报/漏报同时回归) ──
Expand Down
Loading